Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 | ||
|
|
4071be5329 | ||
|
|
8a7d7b09bf | ||
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace | ||
|
|
3fe16fa7f2 | ||
|
|
f199148046 | ||
|
|
38536a5c0c | ||
|
|
286a924fe4 | ||
|
|
3e9e2e899d | ||
|
|
96cd7684f0 | ||
|
|
1af82dfddf | ||
|
|
2cc2c66976 | ||
|
|
a92a389245 | ||
|
|
3d1ce1f45c | ||
|
|
29188d6049 | ||
|
|
4c1ac222a8 | ||
|
|
784315f2ea | ||
|
|
0c6cd55951 | ||
|
|
e01d8e7c49 | ||
|
|
c77161c3e9 | ||
|
|
843ef9cd42 | ||
|
|
e5d5b515ba | ||
|
|
a14087fa7d | ||
|
|
76f1952d61 | ||
|
|
f464e6f2e2 | ||
|
|
f5108de288 | ||
|
|
ee37cf7bd9 | ||
|
|
0d1218fa53 | ||
|
|
a72f199139 | ||
|
|
e0eb0d2079 | ||
|
|
d66abda05a | ||
|
|
6fa4532781 | ||
|
|
2f6d63400a | ||
|
|
0d6e7e0b5a | ||
|
|
aeb02d023d | ||
|
|
60d192a9ec | ||
|
|
cc6a1902bc | ||
|
|
a488bbf832 | ||
|
|
9ee4816fa4 | ||
|
|
db208a1a86 | ||
|
|
3d63ba50ee | ||
|
|
073ba9a252 | ||
|
|
c1a484b97a | ||
|
|
6117886e70 | ||
|
|
ae8744672f | ||
|
|
328ef56af5 | ||
|
|
7714e16547 | ||
|
|
cc31cd033b | ||
|
|
f324793732 | ||
|
|
56d9d740b5 | ||
|
|
e5b02b00e4 | ||
|
|
34cd7dbc59 | ||
|
|
4f72dda6fa | ||
|
|
cc1b5011a1 | ||
|
|
18bb23ad6d | ||
|
|
ae591e4212 | ||
|
|
89d79a0f7d | ||
|
|
d0f75d0c14 | ||
|
|
6b3a7dc09d | ||
|
|
38488f2978 | ||
|
|
7c64749267 | ||
|
|
e8d6098d39 | ||
|
|
5d1a6b7399 | ||
|
|
a816b32426 | ||
|
|
5271d5fbad | ||
|
|
552ec1a542 | ||
|
|
5c807d1581 | ||
|
|
a5b9c26902 | ||
|
|
9a5b974fda | ||
|
|
b563a56563 | ||
|
|
72ee2fb2b7 | ||
|
|
ae927983e7 | ||
|
|
4ceeed71a2 | ||
|
|
95c523738c | ||
|
|
1fcc058dd8 | ||
|
|
ee25e2a4fa | ||
|
|
dabf1100c9 | ||
|
|
0759d89d13 | ||
|
|
01a1728971 | ||
|
|
7bedee2934 | ||
|
|
7143e4e701 | ||
|
|
e8797b8dc2 | ||
|
|
48bd3682f2 | ||
|
|
1e1bdcccce | ||
|
|
aee172a5cd | ||
|
|
d206758729 | ||
|
|
4afc3649cc | ||
|
|
3866bdb335 | ||
|
|
71b33f8623 | ||
|
|
d01c67fc45 | ||
|
|
a39675f975 | ||
|
|
870dee4a29 | ||
|
|
bcfc1f2f52 | ||
|
|
d168fedf1f | ||
|
|
68e263f325 | ||
|
|
105a74bc51 | ||
|
|
dce7e0f688 | ||
|
|
3851786b90 | ||
|
|
8e7d94e6f9 | ||
|
|
25bf67062a | ||
|
|
5d71572df6 | ||
|
|
98f95df8fb | ||
|
|
b7c9227bb8 | ||
|
|
ddb309f6db | ||
|
|
2db4048ed2 | ||
|
|
2f53c2f0ed | ||
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 |
@@ -1,4 +1,4 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
@@ -12,13 +12,17 @@ concurrency:
|
||||
|
||||
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 deploy tools
|
||||
- name: Install toolchain
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends rsync openssh-client
|
||||
apt-get install -y --no-install-recommends \
|
||||
cmake build-essential rsync openssh-client
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,10 +33,26 @@ jobs:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- 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/
|
||||
|
||||
# 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 -c Release -o ./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:
|
||||
@@ -47,6 +67,8 @@ jobs:
|
||||
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 }}
|
||||
@@ -56,11 +78,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
rsync -az --delete \
|
||||
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: Restart service
|
||||
- name: Reload and restart service
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
@@ -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
|
||||
@@ -1,296 +0,0 @@
|
||||
using Microsoft.Playwright;
|
||||
using Microsoft.Playwright.NUnit;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace JoshHeaps.Net.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the acoustic-ranging pipeline against a simulated room. The DSP runs in the browser
|
||||
/// because that is where it runs in production; these tests drive the same files the page loads,
|
||||
/// so there is no second implementation to drift.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EchoDspTests : PageTest
|
||||
{
|
||||
private TestConfiguration Config => TestConfiguration.Instance;
|
||||
|
||||
public override BrowserNewContextOptions ContextOptions() => Config.GetBrowserContextOptions();
|
||||
|
||||
[SetUp]
|
||||
public async Task LoadPipeline()
|
||||
{
|
||||
await Page.GotoAsync(Config.Test.BaseUrl);
|
||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoDsp.js" });
|
||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoSim.js" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matched_Filter_Finds_The_Chirp_Within_One_Sample()
|
||||
{
|
||||
var error = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
||||
const recording = new Float32Array(sampleRate);
|
||||
for (let i = 0; i < recording.length; i++) recording[i] = (Math.random() * 2 - 1) * 0.02;
|
||||
|
||||
const offset = 12345;
|
||||
for (let i = 0; i < chirp.length; i++) recording[offset + i] += chirp[i] * 0.3;
|
||||
|
||||
const peak = EchoDsp.findFirstPeak(EchoDsp.matchedFilterEnvelope(recording, chirp));
|
||||
return Math.abs(peak.index - offset);
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(error, Is.LessThan(1.0), "arrival should be located to within a sample");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Tone_Cannot_Be_Located_But_A_Chirp_Can()
|
||||
{
|
||||
var ratios = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const sidelobeRatio = template => {
|
||||
const recording = new Float32Array(sampleRate / 2);
|
||||
const offset = 8000;
|
||||
for (let i = 0; i < template.length; i++) recording[offset + i] += template[i];
|
||||
|
||||
const envelope = EchoDsp.matchedFilterEnvelope(recording, template);
|
||||
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length);
|
||||
let highest = 0;
|
||||
for (let i = 0; i < envelope.length; i++) {
|
||||
if (Math.abs(i - peak.index) < 200) continue;
|
||||
highest = Math.max(highest, envelope[i]);
|
||||
}
|
||||
return highest / peak.value;
|
||||
};
|
||||
|
||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
||||
const tone = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 5000, endHz: 5000 });
|
||||
return [sidelobeRatio(chirp), sidelobeRatio(tone)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(ratios[1], Is.GreaterThan(0.5), "a tone should correlate almost as well far from the true arrival");
|
||||
Assert.That(ratios[0], Is.LessThan(0.25), "a chirp should give one unambiguous arrival");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clock_Offset_And_Pipeline_Latency_Cancel()
|
||||
{
|
||||
var distance = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const flight = (4.2 / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
const offsetB = 987654;
|
||||
const latencyA = 0.031 * sampleRate;
|
||||
const latencyB = 0.128 * sampleRate;
|
||||
|
||||
return EchoDsp.pairDistance({
|
||||
a1: latencyA,
|
||||
a2: slot + latencyB + flight,
|
||||
b1: offsetB + latencyA + flight,
|
||||
b2: offsetB + slot + latencyB,
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
});
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distance, Is.EqualTo(4.2).Within(0.001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Collocated_Devices_Read_Zero_Before_Calibration()
|
||||
{
|
||||
var distance = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const spacing = (0.19 / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
const latencyA = 0.04 * sampleRate;
|
||||
const latencyB = 0.11 * sampleRate;
|
||||
|
||||
// Two tabs on one machine: one speaker, one microphone, so the self path and the
|
||||
// cross path are the same physical distance.
|
||||
return EchoDsp.pairDistance({
|
||||
a1: latencyA + spacing,
|
||||
a2: slot + latencyB + spacing,
|
||||
b1: latencyA + spacing,
|
||||
b2: slot + latencyB + spacing,
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
});
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distance, Is.EqualTo(0).Within(0.001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Speaker_To_Microphone_Spacing_Is_Added_Back()
|
||||
{
|
||||
var distances = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const truth = 3.0;
|
||||
const epsilonA = 0.18;
|
||||
const epsilonB = 0.04;
|
||||
const samples = metres => (metres / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
|
||||
const peaks = {
|
||||
a1: samples(epsilonA),
|
||||
a2: slot + samples(truth),
|
||||
b1: samples(truth),
|
||||
b2: slot + samples(epsilonB),
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
};
|
||||
|
||||
return [
|
||||
EchoDsp.pairDistance(peaks),
|
||||
EchoDsp.pairDistance({ ...peaks, epsilonA, epsilonB })
|
||||
];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distances[0], Is.EqualTo(3.0 - 0.11).Within(0.005), "uncorrected range reads short");
|
||||
Assert.That(distances[1], Is.EqualTo(3.0).Within(0.005), "correcting for spacing recovers the true range");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Simulated_Room_Recovers_Distances_And_Layout()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const result = EchoSim.runRound({
|
||||
positions: [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]]
|
||||
});
|
||||
return [EchoSim.worstDistanceError(result), EchoSim.worstPositionError(result), result.keep.length];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[2], Is.EqualTo(5), "every device should survive a clean round");
|
||||
Assert.That(errors[0], Is.LessThan(0.05), "worst pairwise range error");
|
||||
Assert.That(errors[1], Is.LessThan(0.15), "worst recovered position error");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Reflection_Louder_Than_The_Direct_Path_Does_Not_Win()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const measure = relativeThreshold => EchoSim.worstDistanceError(EchoSim.runRound({
|
||||
positions: [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]],
|
||||
reflections: [{ extraMetres: 1.8, gain: 5 }],
|
||||
peakOptions: { relativeThreshold }
|
||||
}));
|
||||
|
||||
return [measure(undefined), measure(0.5)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(0.05), "the first arrival is the distance, not the loudest one");
|
||||
Assert.That(errors[1], Is.GreaterThan(1.5),
|
||||
"a threshold high enough to miss the direct path must measure the reflection instead — this is what the default guards against");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Echoes_Inside_The_Correlation_Lobe_Bound_The_Accuracy()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]];
|
||||
const worst = reflectionExtraRange =>
|
||||
EchoSim.worstDistanceError(EchoSim.runRound({ positions, reflectionExtraRange }));
|
||||
|
||||
return [worst([0.4, 4.0]), worst([0.08, 0.4])];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(0.01), "echoes well clear of the direct arrival are rejected outright");
|
||||
Assert.That(errors[1], Is.LessThan(0.15),
|
||||
"echoes arriving inside the correlation lobe cannot be separated and bias the range — this bounds what a device resting on a hard surface can achieve");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Bad_Measurement_Is_Rejected_And_The_Layout_Survives()
|
||||
{
|
||||
var outcome = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.6, 1.3]];
|
||||
const config = EchoSim.buildConfiguration({ positions });
|
||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
||||
|
||||
reports[3].peaks[0] += 9000;
|
||||
|
||||
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
|
||||
const truth = solved.keep.map(index => positions[index]);
|
||||
const aligned = EchoDsp.alignToReference(solved.points, truth);
|
||||
const worst = Math.max(...aligned.map((point, i) => EchoSim.separation(point, truth[i])));
|
||||
const brokenPairSurvived = solved.keep.includes(0) && solved.keep.includes(3);
|
||||
|
||||
return [solved.keep.length, brokenPairSurvived ? 1 : 0, worst];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(outcome[0], Is.EqualTo(4), "exactly one endpoint of the bad pair should be dropped");
|
||||
Assert.That(outcome[1], Is.EqualTo(0), "the impossible pair must not survive");
|
||||
Assert.That(outcome[2], Is.LessThan(0.2), "the remaining layout should be unpoisoned");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Alignment_Undoes_An_Arbitrary_Rotation_And_Mirror()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const reference = [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]];
|
||||
const scramble = (points, angle, mirror) => points.map(([x, y]) => {
|
||||
const mx = x * mirror;
|
||||
return [mx * Math.cos(angle) - y * Math.sin(angle) + 11, mx * Math.sin(angle) + y * Math.cos(angle) - 4];
|
||||
});
|
||||
|
||||
const worst = mirror => {
|
||||
const aligned = EchoDsp.alignToReference(scramble(reference, 0.9, mirror), reference);
|
||||
return Math.max(...aligned.map((point, i) => EchoSim.separation(point, reference[i])));
|
||||
};
|
||||
|
||||
return [worst(1), worst(-1)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(1e-9), "rotation and translation should be recovered exactly");
|
||||
Assert.That(errors[1], Is.LessThan(1e-9), "a mirrored solve should be un-mirrored onto the reference");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Consecutive_Frames_Do_Not_Rotate_Or_Flip()
|
||||
{
|
||||
var drift = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9]];
|
||||
const first = EchoSim.runRound({ positions, seed: 11 });
|
||||
|
||||
const previous = new Array(positions.length).fill(null);
|
||||
first.keep.forEach((device, i) => { previous[device] = first.points[i]; });
|
||||
|
||||
const config = EchoSim.buildConfiguration({ positions, seed: 22 });
|
||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
||||
const second = EchoDsp.solveRound(reports, {
|
||||
speedOfSound: config.speedOfSound,
|
||||
previousPoints: previous
|
||||
});
|
||||
|
||||
return Math.max(...second.keep.map((device, i) => EchoSim.separation(second.points[i], previous[device])));
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(drift, Is.LessThan(0.3), "a stationary room should not move between frames");
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
using Microsoft.Playwright;
|
||||
using Microsoft.Playwright.NUnit;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace JoshHeaps.Net.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
|
||||
/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
|
||||
/// known in advance — this is everything except the acoustics.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EchoRoomTests : PlaywrightTest
|
||||
{
|
||||
private const double TargetMetres = 2.5;
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
private readonly List<IBrowserContext> _contexts = [];
|
||||
private TestConfiguration Config => TestConfiguration.Instance;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task LaunchBrowser()
|
||||
{
|
||||
// Its own Playwright instance and browser: the fake-media launch flags have to be set at
|
||||
// launch time, and the fixture-managed browser is already running by the time tests start.
|
||||
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args =
|
||||
[
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--autoplay-policy=no-user-gesture-required"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public async Task CloseBrowser()
|
||||
{
|
||||
if (_browser is not null) await _browser.CloseAsync();
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task CloseContexts()
|
||||
{
|
||||
foreach (var context in _contexts) await context.CloseAsync();
|
||||
_contexts.Clear();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Two_Devices_Measure_The_Distance_Between_Them()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone");
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
|
||||
var measured = await WaitForMeasurementAsync(first);
|
||||
var alsoMeasured = await WaitForMeasurementAsync(second);
|
||||
|
||||
Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
|
||||
Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone");
|
||||
|
||||
await WaitForMeasurementAsync(first);
|
||||
await second.ClickAsync("#echoLeave");
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
|
||||
await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
|
||||
|
||||
await second.ClickAsync("#echoJoin");
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
|
||||
/// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
|
||||
/// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
|
||||
/// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
await first.EvaluateAsync("() => { window.__seen = []; }");
|
||||
await first.EvaluateAsync("""
|
||||
() => {
|
||||
const original = EchoPage.renderSolved.bind(EchoPage);
|
||||
EchoPage.renderSolved = update => {
|
||||
const range = update.solved?.matrix?.[0]?.[1];
|
||||
if (range != null) window.__seen.push(range);
|
||||
return original(update);
|
||||
};
|
||||
}
|
||||
""");
|
||||
|
||||
await first.WaitForTimeoutAsync(20000);
|
||||
var seen = await first.EvaluateAsync<double[]>("() => window.__seen");
|
||||
var satOut = await second.EvaluateAsync<int>("() => EchoSession.skippedRounds");
|
||||
|
||||
TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
|
||||
Assert.That(satOut, Is.GreaterThan(0),
|
||||
"the stall must actually have cost the device some slots, or this test proves nothing");
|
||||
Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
|
||||
Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
|
||||
"every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
|
||||
{
|
||||
var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
|
||||
|
||||
await page.WaitForFunctionAsync(
|
||||
"() => EchoAudio.highestFrame > 48000",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
|
||||
|
||||
var capture = await page.EvaluateAsync<double[]>("""
|
||||
() => [
|
||||
EchoAudio.context.sampleRate,
|
||||
EchoAudio.warnings.length,
|
||||
EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
|
||||
EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
|
||||
Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
|
||||
]
|
||||
""");
|
||||
|
||||
Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
|
||||
Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
|
||||
Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
|
||||
Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
|
||||
Assert.That(capture[4], Is.LessThan(48000),
|
||||
"the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
|
||||
/// is placed where a room of this geometry would put it, including a different unknown output
|
||||
/// latency per slot so the cancellation is actually exercised.
|
||||
/// </summary>
|
||||
private async Task<IPage> NewDeviceAsync(
|
||||
string roomCode,
|
||||
string name,
|
||||
bool fakeMicrophone = true,
|
||||
int stallMilliseconds = 0)
|
||||
{
|
||||
var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
IgnoreHTTPSErrors = true,
|
||||
Permissions = ["microphone"]
|
||||
});
|
||||
|
||||
_contexts.Add(context);
|
||||
var page = await context.NewPageAsync();
|
||||
page.Console += (_, message) =>
|
||||
{
|
||||
if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
|
||||
};
|
||||
|
||||
await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
|
||||
await page.FillAsync("#echoName", name);
|
||||
if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
|
||||
if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
|
||||
await page.ClickAsync("#echoJoin");
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
|
||||
private const string StallScript = """
|
||||
stallMs => {
|
||||
const original = EchoSession.handleRoundStarting.bind(EchoSession);
|
||||
EchoSession.handleRoundStarting = schedule => {
|
||||
const until = performance.now() + stallMs;
|
||||
while (performance.now() < until) { /* hold the thread */ }
|
||||
return original(schedule);
|
||||
};
|
||||
}
|
||||
""";
|
||||
|
||||
private static async Task<double> WaitForMeasurementAsync(IPage page)
|
||||
{
|
||||
await page.WaitForFunctionAsync(
|
||||
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
|
||||
|
||||
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesizes what the microphone would have heard for this round. The device's own chirp is
|
||||
/// placed at the frame it was actually scheduled for rather than at its nominal slot position,
|
||||
/// so any drift between "when the round said to play" and "when playback was really booked"
|
||||
/// reaches the detector instead of being papered over by the harness.
|
||||
/// </summary>
|
||||
private const string FakeMicrophoneScript = """
|
||||
targetMetres => {
|
||||
const speedOfSound = 343;
|
||||
const epsilonMetres = 0.08;
|
||||
const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
|
||||
|
||||
EchoAudio.read = (startFrame, length) => {
|
||||
const round = EchoSession.pending ?? EchoSession.lastRound;
|
||||
if (!round) return null;
|
||||
|
||||
const chirp = EchoSession.chirp;
|
||||
const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
|
||||
const recording = new Float32Array(length);
|
||||
|
||||
round.schedule.slotOrder.forEach((_, slot) => {
|
||||
const own = slot === round.ownSlot;
|
||||
const origin = own
|
||||
? round.scheduledFrame - round.windowStart
|
||||
: round.leadInSamples + slot * round.slotSamples;
|
||||
const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
|
||||
const amplitude = own ? 1.0 : 0.25;
|
||||
|
||||
for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
|
||||
});
|
||||
|
||||
for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
|
||||
return recording;
|
||||
};
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Membership and peak reporting for acoustic ranging rooms. Nothing that arrives here is audio:
|
||||
/// a report is a handful of sample indices, and the geometry is solved on the clients.
|
||||
/// </summary>
|
||||
public class EchoHub(IEchoRoomStore rooms) : Hub
|
||||
{
|
||||
/// <summary>Join (or create) a room and receive an id plus the current roster.</summary>
|
||||
public async Task<EchoJoinResult> JoinRoom(string roomCode, string displayName, int sampleRate)
|
||||
{
|
||||
var result = rooms.Join(roomCode, Context.ConnectionId, displayName, sampleRate);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(result.RoomCode));
|
||||
await Clients.Group(GroupFor(result.RoomCode)).SendAsync("RoomChanged", result.Room);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>File this device's arrival indices for the room's open round.</summary>
|
||||
public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
|
||||
|
||||
/// <summary>
|
||||
/// Server clock, for estimating each device's offset from it. Only needs to be good to a few
|
||||
/// tens of milliseconds: it decides which chirp is whose, never how far away anything is.
|
||||
/// </summary>
|
||||
public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
public async Task<bool> LeaveRoom() => await RemoveFromRoom();
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
await RemoveFromRoom();
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
internal static string GroupFor(string roomCode) => $"echo:{roomCode.ToUpperInvariant()}";
|
||||
|
||||
private async Task<bool> RemoveFromRoom()
|
||||
{
|
||||
var (roomCode, room) = rooms.Leave(Context.ConnectionId);
|
||||
if (roomCode is null) return false;
|
||||
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupFor(roomCode));
|
||||
if (room is not null) await Clients.Group(GroupFor(roomCode)).SendAsync("RoomChanged", room);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>A device taking part in a ranging room.</summary>
|
||||
public sealed class EchoDevice
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string ConnectionId { get; init; }
|
||||
public required string DisplayName { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
|
||||
/// </summary>
|
||||
public sealed class EchoRound
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public int SlotMilliseconds { get; init; }
|
||||
public DateTimeOffset Deadline { get; init; }
|
||||
public Dictionary<string, EchoPeakReport> Reports { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one device heard. Peaks are fractional sample indices into that device's own continuous
|
||||
/// recording, one per slot, null where a chirp was not detected. Nothing else is ever uploaded.
|
||||
/// </summary>
|
||||
public sealed class EchoPeakReport
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string RoundId { get; init; }
|
||||
public int Slot { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public double Epsilon { get; init; }
|
||||
public double?[] Peaks { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class EchoJoinResult
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string RoomCode { get; init; }
|
||||
public required EchoRoomSnapshot Room { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EchoRoomSnapshot
|
||||
{
|
||||
public required string RoomCode { get; init; }
|
||||
public required EchoDeviceSnapshot[] Devices { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EchoDeviceSnapshot
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Tells every device when to chirp: its own slot index and how long a slot lasts.</summary>
|
||||
public sealed class EchoRoundSchedule
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public int SlotMilliseconds { get; init; }
|
||||
public int TailMilliseconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When slot zero should sound, in server time, set far enough ahead that every device can
|
||||
/// receive the message and book the playback before it arrives. Devices schedule against this
|
||||
/// rather than against message arrival, so one slow client cannot drag its chirp into another
|
||||
/// device's slot and invalidate the round for everybody.
|
||||
/// </summary>
|
||||
public long StartsAtUnixMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
|
||||
/// so the server never needs the ranging maths.
|
||||
/// </summary>
|
||||
public sealed class EchoRoundResult
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public required EchoPeakReport[] Reports { get; init; }
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class EchoModel : PageModel
|
||||
public class CasinoModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
@@ -0,0 +1,490 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
|
||||
@{
|
||||
ViewData["Title"] = "Texas Hold'em Dealer";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="holdemApp">
|
||||
<header id="holdemHeader">
|
||||
<div id="holdemHeaderLeft">
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="betsLayer" aria-hidden="true"></div>
|
||||
<div id="seatsLayer"></div>
|
||||
</div>
|
||||
<p id="tableHint" class="tableHint"></p>
|
||||
<p class="tableHintActions">
|
||||
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside id="holdemSidebar">
|
||||
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatEditorHeader">
|
||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
|
||||
</div>
|
||||
<div id="seatEditorBody">
|
||||
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="sidebarPanel" open>
|
||||
<summary>Table Setup</summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Blinds move</span>
|
||||
<select id="directionSelect">
|
||||
<option value="clockwise" selected>Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Dedicated dealer (not a seat)</span>
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
<input type="number" id="smallBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Big blind</span>
|
||||
<input type="number" id="bigBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>Starting stack ($)</span>
|
||||
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>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>Net</span>
|
||||
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
|
||||
</div>
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
<span id="bankCashIn"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Chips in play</span>
|
||||
<span id="bankChipsInPlay"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
<h3 class="panelSubhead">Settle up</h3>
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div id="handHistoryList"></div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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="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="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="Odd chip">
|
||||
<h3>The odd chip in a split pot</h3>
|
||||
<p>
|
||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||
player left of the button, not to whoever the dealer happened to tick
|
||||
first. Pot payouts here order the winners that way, so the leftover lands
|
||||
on the right seat every time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="String bet">
|
||||
<h3>Betting in one motion — the string bet</h3>
|
||||
<p>
|
||||
A bet is whatever goes in on the first trip to the stack, unless the
|
||||
amount was said out loud first. Reaching back for more chips without
|
||||
having said "raise" is a string bet, and only the first motion stands.
|
||||
Saying the number is always enough on its own: "raise to sixty" binds
|
||||
whatever order the chips arrive in.
|
||||
</p>
|
||||
<p>
|
||||
It's the rule home games invoke most and enforce least, and both are
|
||||
fine — the point is that a player can't watch the table react and then
|
||||
decide how much to bet. Say it, or push it all at once.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="rulingsSource">
|
||||
Follows the Tournament Directors Association rules, which most rooms and
|
||||
home games take their lead from.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="seatMenuOverlay" hidden>
|
||||
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeader">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cardPickerOverlay" hidden>
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="cardPickerHeader">
|
||||
<h2 id="cardPickerTitle"></h2>
|
||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
<div id="cardPickerSlots"></div>
|
||||
<div id="cardPickerGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="actionBar" aria-live="polite" hidden></div>
|
||||
|
||||
<div id="toastHost" aria-live="polite"></div>
|
||||
|
||||
<div id="confirmOverlay" hidden>
|
||||
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Casino
|
||||
{
|
||||
public class TexasHoldemModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
|
||||
@{
|
||||
ViewData["Title"] = "Quick Split";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="splitApp">
|
||||
<header id="splitHeader">
|
||||
<h1>Quick Split</h1>
|
||||
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
|
||||
</header>
|
||||
|
||||
<div id="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
|
||||
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
|
||||
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
|
||||
</div>
|
||||
|
||||
<div id="splitResult" aria-live="polite">
|
||||
<div id="splitResultLabel">Each player gets</div>
|
||||
<div id="splitResultAmount">$0.00</div>
|
||||
<div id="splitOddNote" hidden></div>
|
||||
<div id="splitDustNote" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="unevenResult" hidden aria-live="polite">
|
||||
<div id="unevenPots"></div>
|
||||
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
|
||||
</div>
|
||||
|
||||
<section class="splitField unevenOnly" hidden>
|
||||
<span class="splitLabel">What each player put in</span>
|
||||
<div id="unevenRows"></div>
|
||||
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
|
||||
</section>
|
||||
|
||||
<section class="splitField evenOnly">
|
||||
<label for="potInput" class="splitLabel">Pot</label>
|
||||
<div class="potInputWrap">
|
||||
<span class="potCurrency">$</span>
|
||||
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
|
||||
placeholder="0" aria-label="Pot amount" />
|
||||
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="splitField evenOnly">
|
||||
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||
</section>
|
||||
|
||||
<section class="splitField evenOnly">
|
||||
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||
</section>
|
||||
|
||||
<div id="splitActions">
|
||||
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
|
||||
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||
</div>
|
||||
|
||||
<p class="splitFootnote evenOnly">
|
||||
If somebody was all-in for less than the others, switch to
|
||||
<strong>Someone's all-in</strong> above.
|
||||
</p>
|
||||
|
||||
<p class="splitFootnote unevenOnly" hidden>
|
||||
Pots are listed with who can win each. Award them by hand strength, best hand
|
||||
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
|
||||
keeps a log.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template id="unevenRowTemplate">
|
||||
<div class="unevenRow">
|
||||
<span class="unevenRowLabel"></span>
|
||||
<div class="unevenAmountWrap">
|
||||
<span class="unevenCurrency">$</span>
|
||||
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
|
||||
</div>
|
||||
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
|
||||
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
|
||||
{
|
||||
public class QuickSplitModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.EchoModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Echo";
|
||||
}
|
||||
|
||||
<div id="echoHeader">
|
||||
<h1>Echo</h1>
|
||||
<p class="echo-blurb">
|
||||
Two devices measure the distance between them by chirping at each other and timing the answer.
|
||||
Each one records continuously and only ever compares arrivals inside its own recording, so no
|
||||
clock synchronisation is needed and the unknown audio latency cancels out.
|
||||
</p>
|
||||
<p class="echo-privacy">Only sample numbers leave your device. The recording never does.</p>
|
||||
</div>
|
||||
|
||||
<div id="echoControls">
|
||||
<label>Room <input id="echoRoom" type="text" maxlength="6" autocomplete="off" spellcheck="false" /></label>
|
||||
<label>Name <input id="echoName" type="text" maxlength="16" placeholder="this device" autocomplete="off" /></label>
|
||||
<label>
|
||||
Speaker to mic (m)
|
||||
<input id="echoEpsilon" type="number" min="0" max="0.5" step="0.01" />
|
||||
</label>
|
||||
<button id="echoJoin">Join and listen</button>
|
||||
<button id="echoLeave" hidden>Leave</button>
|
||||
</div>
|
||||
|
||||
<p id="echoStatus" class="echo-status">Pick a room, then open the same room on a second device.</p>
|
||||
<p class="echo-share">Join link: <a id="echoShareLink" href="#"></a></p>
|
||||
<div id="echoWarnings"></div>
|
||||
|
||||
<div id="echoReadout" class="echo-readout"><span class="echo-readout-idle">not measuring</span></div>
|
||||
|
||||
<div id="echoPanels">
|
||||
<section class="echo-panel">
|
||||
<h2>Devices</h2>
|
||||
<ul id="echoRoster"></ul>
|
||||
</section>
|
||||
<section class="echo-panel">
|
||||
<h2>Ranges</h2>
|
||||
<div id="echoPairs"></div>
|
||||
</section>
|
||||
<section class="echo-panel">
|
||||
<h2>Timing</h2>
|
||||
<p class="echo-hint">How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.</p>
|
||||
<div id="echoDiagnostics"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="echo-panel echo-panel-wide">
|
||||
<h2>Matched filter</h2>
|
||||
<p class="echo-hint">Each spike is a chirp arriving. The bright one is this device hearing itself.</p>
|
||||
<canvas id="echoTrace"></canvas>
|
||||
</section>
|
||||
|
||||
<a class="echo-back" href="/">← Back</a>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoDsp.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoAudio.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoSession.js"></script>
|
||||
<script src="~/js/EchoScripts/echoMain.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/variables.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/echo/echo.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -31,12 +30,6 @@ builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||
|
||||
builder.Services.Configure<EchoRoundSettings>(configuration.GetSection(EchoRoundSettings.SectionName));
|
||||
builder.Services.AddSingleton(provider =>
|
||||
provider.GetRequiredService<IOptions<EchoRoundSettings>>().Value);
|
||||
builder.Services.AddSingleton<IEchoRoomStore, EchoRoomStore>();
|
||||
builder.Services.AddHostedService<EchoRoundService>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
@@ -79,6 +72,4 @@ app.MapControllers();
|
||||
|
||||
app.MapHub<ChessHub>("/chessHub");
|
||||
|
||||
app.MapHub<EchoHub>("/echoHub");
|
||||
|
||||
app.Run();
|
||||
@@ -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()
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ranging rooms. Singleton: rooms are process-wide and short-lived, and the site runs
|
||||
/// as a single instance, so there is nothing to persist and no backplane to coordinate.
|
||||
/// </summary>
|
||||
public sealed class EchoRoomStore : IEchoRoomStore
|
||||
{
|
||||
private sealed class Room
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public List<EchoDevice> Devices { get; } = [];
|
||||
public EchoRound? Round { get; set; }
|
||||
public int RoundCounter { get; set; }
|
||||
public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _roomsByConnection = [];
|
||||
|
||||
public EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate)
|
||||
{
|
||||
var room = _rooms.GetOrAdd(roomCode, code => new Room { Code = code });
|
||||
var device = new EchoDevice
|
||||
{
|
||||
DeviceId = Guid.NewGuid().ToString("N")[..8],
|
||||
ConnectionId = connectionId,
|
||||
DisplayName = displayName,
|
||||
SampleRate = sampleRate
|
||||
};
|
||||
|
||||
lock (room)
|
||||
{
|
||||
room.Devices.RemoveAll(existing => existing.ConnectionId == connectionId);
|
||||
room.Devices.Add(device);
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
_roomsByConnection[connectionId] = room.Code;
|
||||
|
||||
return new EchoJoinResult { DeviceId = device.DeviceId, RoomCode = room.Code, Room = SnapshotLocked(room) };
|
||||
}
|
||||
}
|
||||
|
||||
public (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId)
|
||||
{
|
||||
if (!_roomsByConnection.TryRemove(connectionId, out var roomCode)) return (null, null);
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return (roomCode, null);
|
||||
|
||||
lock (room)
|
||||
{
|
||||
room.Devices.RemoveAll(device => device.ConnectionId == connectionId);
|
||||
room.Round = null;
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
|
||||
if (room.Devices.Count == 0) _rooms.TryRemove(roomCode, out _);
|
||||
|
||||
return (roomCode, SnapshotLocked(room));
|
||||
}
|
||||
}
|
||||
|
||||
public EchoRoomSnapshot? Snapshot(string roomCode)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room) return SnapshotLocked(room);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> MeasurableRooms =>
|
||||
[.. _rooms.Values.Where(room => room.Devices.Count >= 2).Select(room => room.Code)];
|
||||
|
||||
public EchoRoundSchedule? StartRound(
|
||||
string roomCode,
|
||||
int slotMilliseconds,
|
||||
int tailMilliseconds,
|
||||
TimeSpan lead,
|
||||
TimeSpan grace)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
if (room.Round is not null || room.Devices.Count < 2) return null;
|
||||
|
||||
var slotOrder = RotatedSlotOrder(room);
|
||||
var startsAt = DateTimeOffset.UtcNow + lead;
|
||||
var duration = TimeSpan.FromMilliseconds(slotOrder.Length * slotMilliseconds + tailMilliseconds);
|
||||
|
||||
room.Round = new EchoRound
|
||||
{
|
||||
RoundId = Guid.NewGuid().ToString("N")[..12],
|
||||
SlotOrder = slotOrder,
|
||||
SlotMilliseconds = slotMilliseconds,
|
||||
Deadline = startsAt + duration + grace
|
||||
};
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
|
||||
return new EchoRoundSchedule
|
||||
{
|
||||
RoundId = room.Round.RoundId,
|
||||
SlotOrder = slotOrder,
|
||||
SlotMilliseconds = slotMilliseconds,
|
||||
TailMilliseconds = tailMilliseconds,
|
||||
StartsAtUnixMs = startsAt.ToUnixTimeMilliseconds()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool Report(string connectionId, EchoPeakReport report)
|
||||
{
|
||||
var device = FindDevice(connectionId, out var room);
|
||||
if (device is null || room is null) return false;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
if (room.Round?.RoundId != report.RoundId) return false;
|
||||
if (!room.Round.SlotOrder.Contains(device.DeviceId)) return false;
|
||||
|
||||
room.Round.Reports[device.DeviceId] = report;
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public EchoRoundResult? TryCloseRound(string roomCode)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
var round = room.Round;
|
||||
if (round is null) return null;
|
||||
|
||||
var everyoneReported = round.SlotOrder.All(round.Reports.ContainsKey);
|
||||
if (!everyoneReported && DateTimeOffset.UtcNow < round.Deadline) return null;
|
||||
|
||||
room.Round = null;
|
||||
|
||||
return new EchoRoundResult
|
||||
{
|
||||
RoundId = round.RoundId,
|
||||
SlotOrder = round.SlotOrder,
|
||||
Reports = [.. round.SlotOrder.Where(round.Reports.ContainsKey).Select(id => round.Reports[id])]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public int PruneIdle(TimeSpan idleFor)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - idleFor;
|
||||
var stale = _rooms.Values.Where(room => room.LastActivity < cutoff).Select(room => room.Code).ToList();
|
||||
|
||||
foreach (var code in stale) _rooms.TryRemove(code, out _);
|
||||
|
||||
return stale.Count;
|
||||
}
|
||||
|
||||
private static EchoRoomSnapshot SnapshotLocked(Room room) =>
|
||||
new()
|
||||
{
|
||||
RoomCode = room.Code,
|
||||
Devices =
|
||||
[
|
||||
.. room.Devices.Select(device => new EchoDeviceSnapshot
|
||||
{
|
||||
DeviceId = device.DeviceId,
|
||||
DisplayName = device.DisplayName,
|
||||
SampleRate = device.SampleRate
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
private static string[] RotatedSlotOrder(Room room)
|
||||
{
|
||||
var offset = room.RoundCounter++ % room.Devices.Count;
|
||||
return [.. room.Devices.Skip(offset).Concat(room.Devices.Take(offset)).Select(device => device.DeviceId)];
|
||||
}
|
||||
|
||||
private EchoDevice? FindDevice(string connectionId, out Room? room)
|
||||
{
|
||||
room = null;
|
||||
if (!_roomsByConnection.TryGetValue(connectionId, out var roomCode)) return null;
|
||||
if (!_rooms.TryGetValue(roomCode, out room)) return null;
|
||||
|
||||
lock (room) return room.Devices.FirstOrDefault(device => device.ConnectionId == connectionId);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class EchoRoundSettings
|
||||
{
|
||||
public const string SectionName = "Echo";
|
||||
|
||||
/// <summary>
|
||||
/// Slot length. Wide enough that no device's chirp can land in another's search window once
|
||||
/// unknown output latency (tens of milliseconds, different per device) and message jitter are
|
||||
/// accounted for.
|
||||
/// </summary>
|
||||
public int SlotMilliseconds { get; set; } = 500;
|
||||
|
||||
/// <summary>Time after the last chirp for propagation, detection and reporting.</summary>
|
||||
public int TailMilliseconds { get; set; } = 700;
|
||||
|
||||
public int GraceMilliseconds { get; set; } = 1500;
|
||||
public int GapMilliseconds { get; set; } = 250;
|
||||
public int IdleRoomMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// How far ahead a round is announced. Must exceed the worst message delivery plus client
|
||||
/// stall, or a device will find its slot already gone and sit the round out.
|
||||
/// </summary>
|
||||
public int LeadMilliseconds { get; set; } = 600;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives continuous ranging: opens a round for every room that has two or more devices, closes it
|
||||
/// once everyone has reported or the deadline passes, and broadcasts the raw peak table.
|
||||
/// </summary>
|
||||
public sealed class EchoRoundService(
|
||||
IEchoRoomStore rooms,
|
||||
IHubContext<EchoHub> hub,
|
||||
EchoRoundSettings settings,
|
||||
ILogger<EchoRoundService> logger) : BackgroundService
|
||||
{
|
||||
private readonly Dictionary<string, DateTimeOffset> _nextRoundAllowedAt = [];
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var lastPrune = DateTimeOffset.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync();
|
||||
lastPrune = PruneIfDue(lastPrune);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Echo round tick failed");
|
||||
}
|
||||
|
||||
await Task.Delay(50, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickAsync()
|
||||
{
|
||||
foreach (var roomCode in rooms.MeasurableRooms)
|
||||
{
|
||||
await CloseFinishedRoundAsync(roomCode);
|
||||
await OpenRoundIfDueAsync(roomCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseFinishedRoundAsync(string roomCode)
|
||||
{
|
||||
var result = rooms.TryCloseRound(roomCode);
|
||||
if (result is null) return;
|
||||
|
||||
_nextRoundAllowedAt[roomCode] = DateTimeOffset.UtcNow.AddMilliseconds(settings.GapMilliseconds);
|
||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundComplete", result);
|
||||
}
|
||||
|
||||
private async Task OpenRoundIfDueAsync(string roomCode)
|
||||
{
|
||||
if (_nextRoundAllowedAt.TryGetValue(roomCode, out var earliest) && DateTimeOffset.UtcNow < earliest) return;
|
||||
|
||||
var schedule = rooms.StartRound(
|
||||
roomCode,
|
||||
settings.SlotMilliseconds,
|
||||
settings.TailMilliseconds,
|
||||
TimeSpan.FromMilliseconds(settings.LeadMilliseconds),
|
||||
TimeSpan.FromMilliseconds(settings.GraceMilliseconds));
|
||||
|
||||
if (schedule is null) return;
|
||||
|
||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundStarting", schedule);
|
||||
}
|
||||
|
||||
private DateTimeOffset PruneIfDue(DateTimeOffset lastPrune)
|
||||
{
|
||||
if (DateTimeOffset.UtcNow - lastPrune < TimeSpan.FromMinutes(1)) return lastPrune;
|
||||
|
||||
var pruned = rooms.PruneIdle(TimeSpan.FromMinutes(settings.IdleRoomMinutes));
|
||||
if (pruned > 0) logger.LogInformation("Pruned {Count} idle echo room(s)", pruned);
|
||||
|
||||
return DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
|
||||
if (line == token) break;
|
||||
}
|
||||
|
||||
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
|
||||
// here forever — that would block GameStore's removal task and leave the finished game
|
||||
// stuck in the store, cluttering the watch page indefinitely.
|
||||
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Send("quit");
|
||||
_stdin.Close();
|
||||
await _p.WaitForExitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (!_p.HasExited)
|
||||
_p.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
_p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of ranging rooms. Holds only device identity and the sample indices each
|
||||
/// device reported, so the server is a scheduler and a relay — it never sees audio.
|
||||
/// </summary>
|
||||
public interface IEchoRoomStore
|
||||
{
|
||||
/// <summary>Add a device to a room, creating the room if this is the first arrival.</summary>
|
||||
EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
|
||||
|
||||
/// <summary>Remove whichever device owns this connection, returning the room it left.</summary>
|
||||
(string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
|
||||
|
||||
/// <summary>Snapshot of a room's roster, or null if the room is gone.</summary>
|
||||
EchoRoomSnapshot? Snapshot(string roomCode);
|
||||
|
||||
/// <summary>Room codes with at least two devices, which is the minimum for a measurement.</summary>
|
||||
IReadOnlyCollection<string> MeasurableRooms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Open a new round for a room, rotating which device chirps first so no single device is
|
||||
/// permanently the slot-order anchor.
|
||||
/// </summary>
|
||||
EchoRoundSchedule? StartRound(
|
||||
string roomCode,
|
||||
int slotMilliseconds,
|
||||
int tailMilliseconds,
|
||||
TimeSpan lead,
|
||||
TimeSpan grace);
|
||||
|
||||
/// <summary>File a device's peaks against the room's open round.</summary>
|
||||
bool Report(string connectionId, EchoPeakReport report);
|
||||
|
||||
/// <summary>
|
||||
/// Close the open round if every device has reported or its deadline has passed, returning the
|
||||
/// reports to broadcast.
|
||||
/// </summary>
|
||||
EchoRoundResult? TryCloseRound(string roomCode);
|
||||
|
||||
/// <summary>Drop rooms that have had no activity for longer than <paramref name="idleFor"/>.</summary>
|
||||
int PruneIdle(TimeSpan idleFor);
|
||||
}
|
||||
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,541 @@
|
||||
/* 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 {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.unevenAmount {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.unevenRemoveBtn {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6a6a6a;
|
||||
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;
|
||||
gap: 0.6rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #6f6f6f;
|
||||
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,287 +0,0 @@
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
margin: 0;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
#echoHeader {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
#echoHeader h1 {
|
||||
color: var(--color-heading);
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.echo-blurb {
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.echo-privacy {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#echoControls {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 0 auto 1rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
#echoControls label {
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
gap: 0.3rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
#echoControls input {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-heading);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
width: 7rem;
|
||||
}
|
||||
|
||||
#echoControls input:focus {
|
||||
border-color: var(--color-accent-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#echoControls button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
#echoControls button:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
#echoControls button:disabled {
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.echo-status,
|
||||
.echo-share {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 0 auto 0.4rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-share a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#echoWarnings {
|
||||
margin: 0 auto;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-warning {
|
||||
background: rgba(255, 170, 0, 0.08);
|
||||
border: 1px solid rgba(255, 170, 0, 0.35);
|
||||
border-radius: 6px;
|
||||
color: #ffcc66;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
}
|
||||
|
||||
.echo-readout {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 46rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.echo-metres {
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(3.5rem, 14vw, 7rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.echo-metres small {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.28em;
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.echo-readout-detail,
|
||||
.echo-readout-idle {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#echoPanels {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
margin: 0 auto;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.echo-panel-wide {
|
||||
margin: 1rem auto 0;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-panel h2 {
|
||||
color: var(--color-heading-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.echo-hint {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
margin: -0.4rem 0 0.75rem;
|
||||
}
|
||||
|
||||
#echoRoster {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.echo-device {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.echo-device:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.echo-device-self {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
#echoPairs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#echoPairs table {
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#echoPairs th {
|
||||
color: var(--color-text-subtle);
|
||||
font-weight: 500;
|
||||
padding: 0 0.6rem 0.4rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#echoPairs td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-heading-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.4rem 0.6rem 0.4rem 0;
|
||||
}
|
||||
|
||||
.echo-dropped td {
|
||||
color: var(--color-text-subtle);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.echo-slots {
|
||||
list-style: none;
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.echo-slots li {
|
||||
color: var(--color-heading-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.echo-slots .echo-missed {
|
||||
color: #ff7676;
|
||||
}
|
||||
|
||||
.echo-diag-line {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.echo-diag-line strong {
|
||||
color: #ff7676;
|
||||
}
|
||||
|
||||
#echoTrace {
|
||||
display: block;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.echo-back {
|
||||
color: var(--color-text-subtle);
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 2rem auto 0;
|
||||
max-width: 46rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.echo-back:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
* Microphone capture and chirp playback, both indexed by AudioContext frame.
|
||||
*
|
||||
* Frames, not wall-clock: currentTime * sampleRate is exactly the frame number, so a playback
|
||||
* scheduled at an audio-clock time converts directly into a position in the recording. Nothing here
|
||||
* depends on a timer firing on schedule, which matters because a hidden tab's timers are throttled
|
||||
* to about once a second and its chirp would land in another device's slot.
|
||||
*/
|
||||
const EchoAudio = {
|
||||
TARGET_SAMPLE_RATE: 48000,
|
||||
RING_SECONDS: 20,
|
||||
BLUETOOTH_HINTS: /bluetooth|airpod|hands-?free|headset|\bbt\b|wireless/i,
|
||||
|
||||
context: null,
|
||||
stream: null,
|
||||
capture: null,
|
||||
ring: null,
|
||||
highestFrame: 0,
|
||||
peakAmplitude: 0,
|
||||
warnings: [],
|
||||
onSamples: null,
|
||||
|
||||
async start() {
|
||||
if (this.context) return this.describe();
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
// Echo cancellation exists to delete sounds this device just played, which is exactly
|
||||
// the measurement. Gain control and noise suppression distort the chirp's envelope.
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
channelCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
this.context = new AudioContext({ sampleRate: this.TARGET_SAMPLE_RATE, latencyHint: "interactive" });
|
||||
await this.context.audioWorklet.addModule("/js/EchoScripts/EchoCaptureProcessor.js");
|
||||
await this.context.resume();
|
||||
|
||||
this.ring = new Float32Array(Math.ceil(this.context.sampleRate * this.RING_SECONDS));
|
||||
this.attachCapture();
|
||||
this.warnings = this.inspectDevice();
|
||||
|
||||
return this.describe();
|
||||
},
|
||||
|
||||
attachCapture() {
|
||||
const source = this.context.createMediaStreamSource(this.stream);
|
||||
this.capture = new AudioWorkletNode(this.context, "echo-capture", { numberOfOutputs: 0 });
|
||||
this.capture.port.onmessage = ({ data }) => this.write(data.frame, data.samples);
|
||||
source.connect(this.capture);
|
||||
return this.capture;
|
||||
},
|
||||
|
||||
write(frame, samples) {
|
||||
const capacity = this.ring.length;
|
||||
let loudest = 0;
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
this.ring[(frame + i) % capacity] = samples[i];
|
||||
loudest = Math.max(loudest, Math.abs(samples[i]));
|
||||
}
|
||||
|
||||
this.peakAmplitude = Math.max(this.peakAmplitude * 0.95, loudest);
|
||||
this.highestFrame = Math.max(this.highestFrame, frame + samples.length);
|
||||
this.onSamples?.(this.highestFrame);
|
||||
return this.highestFrame;
|
||||
},
|
||||
|
||||
/** The recording position for an AudioContext time. Exact: currentTime * sampleRate is a frame. */
|
||||
frameAt(contextTime) {
|
||||
return Math.round(contextTime * this.context.sampleRate);
|
||||
},
|
||||
|
||||
/** Copy an absolute frame range out of the ring, or null if it is not (or no longer) held. */
|
||||
read(startFrame, length) {
|
||||
if (length <= 0) return null;
|
||||
if (startFrame + length > this.highestFrame) return null;
|
||||
if (startFrame < this.highestFrame - this.ring.length) return null;
|
||||
|
||||
const capacity = this.ring.length;
|
||||
const window = new Float32Array(length);
|
||||
for (let i = 0; i < length; i++) window[i] = this.ring[((startFrame + i) % capacity + capacity) % capacity];
|
||||
|
||||
return window;
|
||||
},
|
||||
|
||||
/**
|
||||
* Schedule a chirp on the audio clock and return the frame it was scheduled for. When it
|
||||
* actually leaves the speaker is later by an unknown output latency and does not need to be
|
||||
* known — it is recovered from the recording of it.
|
||||
*/
|
||||
play(chirp, atContextTime, gain = 0.5) {
|
||||
const when = Math.max(atContextTime, this.context.currentTime);
|
||||
const buffer = this.context.createBuffer(1, chirp.length, this.context.sampleRate);
|
||||
buffer.copyToChannel(chirp, 0);
|
||||
|
||||
const source = this.context.createBufferSource();
|
||||
const volume = this.context.createGain();
|
||||
volume.gain.value = gain;
|
||||
source.buffer = buffer;
|
||||
source.connect(volume).connect(this.context.destination);
|
||||
source.start(when);
|
||||
|
||||
return this.frameAt(when);
|
||||
},
|
||||
|
||||
/** How far behind the audio clock the main thread's view of the recording is running. */
|
||||
captureLagSeconds() {
|
||||
return this.context.currentTime - this.highestFrame / this.context.sampleRate;
|
||||
},
|
||||
|
||||
/** Reported output latency, which bounds how late a scheduled chirp can reach the microphone. */
|
||||
outputLatencySeconds() {
|
||||
return (this.context.outputLatency || 0) + (this.context.baseLatency || 0);
|
||||
},
|
||||
|
||||
inspectDevice() {
|
||||
const warnings = [];
|
||||
const track = this.stream.getAudioTracks()[0];
|
||||
const settings = track?.getSettings() ?? {};
|
||||
|
||||
if (this.context.sampleRate !== this.TARGET_SAMPLE_RATE)
|
||||
warnings.push(`Running at ${this.context.sampleRate}Hz instead of 48000Hz — ranges stay correct but resolution drops.`);
|
||||
|
||||
if (this.BLUETOOTH_HINTS.test(track?.label ?? ""))
|
||||
warnings.push("This looks like a Bluetooth microphone. Bluetooth resamples and re-times the stream, which breaks the measurement — switch to the built-in speaker and microphone.");
|
||||
|
||||
for (const [name, label] of [["echoCancellation", "Echo cancellation"], ["autoGainControl", "Auto gain"], ["noiseSuppression", "Noise suppression"]])
|
||||
if (settings[name] === true) warnings.push(`${label} could not be turned off on this device — the measurement will be unreliable.`);
|
||||
|
||||
return warnings;
|
||||
},
|
||||
|
||||
describe() {
|
||||
const track = this.stream.getAudioTracks()[0];
|
||||
|
||||
return {
|
||||
sampleRate: this.context.sampleRate,
|
||||
label: track?.label ?? "microphone",
|
||||
outputLatencyMs: Math.round(this.outputLatencySeconds() * 1000),
|
||||
warnings: this.warnings
|
||||
};
|
||||
},
|
||||
|
||||
async stop() {
|
||||
this.stream?.getTracks().forEach(track => track.stop());
|
||||
await this.context?.close();
|
||||
this.context = null;
|
||||
this.stream = null;
|
||||
this.capture = null;
|
||||
this.ring = null;
|
||||
this.highestFrame = 0;
|
||||
this.onSamples = null;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") window.EchoAudio = EchoAudio;
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Capture worklet. Every block is tagged with the AudioContext frame it started at, which makes the
|
||||
* context's own clock the index for all captured audio: a scheduled playback time converts to a
|
||||
* recording position exactly, with no dependence on when the main thread got round to noticing.
|
||||
*
|
||||
* A worklet rather than ScriptProcessorNode because a dropped buffer would break the continuity
|
||||
* that the sample count depends on.
|
||||
*/
|
||||
const BLOCK_SAMPLES = 2048;
|
||||
|
||||
class EchoCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.block = new Float32Array(BLOCK_SAMPLES);
|
||||
this.filled = 0;
|
||||
this.blockStartFrame = 0;
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const channel = inputs[0]?.[0];
|
||||
if (!channel) return true;
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
if (this.filled === 0) this.blockStartFrame = currentFrame + i;
|
||||
this.block[this.filled++] = channel[i];
|
||||
if (this.filled === BLOCK_SAMPLES) this.flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
flush() {
|
||||
const samples = this.block.slice(0, this.filled);
|
||||
this.port.postMessage({ frame: this.blockStartFrame, samples }, [samples.buffer]);
|
||||
this.filled = 0;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("echo-capture", EchoCaptureProcessor);
|
||||
@@ -1,585 +0,0 @@
|
||||
/*
|
||||
* Pure signal-processing and geometry for acoustic ranging. No DOM, no Web Audio, no network:
|
||||
* everything here is a function of its arguments so the simulator and the tests can drive the
|
||||
* exact code the page runs.
|
||||
*/
|
||||
const EchoDsp = {
|
||||
speedOfSound(temperatureCelsius = 20) {
|
||||
return 331.3 + 0.606 * temperatureCelsius;
|
||||
},
|
||||
|
||||
nextPowerOfTwo(value) {
|
||||
let size = 1;
|
||||
while (size < value) size <<= 1;
|
||||
return size;
|
||||
},
|
||||
|
||||
/**
|
||||
* Linear frequency sweep, tapered at both ends. A sweep is used rather than a tone because a
|
||||
* tone's autocorrelation peaks once per period, leaving no unambiguous arrival to measure.
|
||||
*/
|
||||
makeChirp({ sampleRate, durationSeconds, startHz, endHz, taperFraction = 0.15 }) {
|
||||
const length = Math.round(sampleRate * durationSeconds);
|
||||
const sweepRate = (endHz - startHz) / durationSeconds;
|
||||
const chirp = new Float32Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const t = i / sampleRate;
|
||||
chirp[i] = Math.sin(2 * Math.PI * (startHz * t + 0.5 * sweepRate * t * t));
|
||||
}
|
||||
|
||||
return this.applyTaper(chirp, taperFraction);
|
||||
},
|
||||
|
||||
applyTaper(signal, fraction) {
|
||||
const edge = Math.max(1, Math.floor(signal.length * fraction));
|
||||
|
||||
for (let i = 0; i < edge; i++) {
|
||||
const window = 0.5 - 0.5 * Math.cos((Math.PI * i) / edge);
|
||||
signal[i] *= window;
|
||||
signal[signal.length - 1 - i] *= window;
|
||||
}
|
||||
|
||||
return signal;
|
||||
},
|
||||
|
||||
twiddles(size) {
|
||||
this._twiddleCache ??= new Map();
|
||||
const cached = this._twiddleCache.get(size);
|
||||
if (cached) return cached;
|
||||
|
||||
const half = size >> 1;
|
||||
const table = { cos: new Float64Array(half), sin: new Float64Array(half) };
|
||||
|
||||
for (let i = 0; i < half; i++) {
|
||||
const angle = (-2 * Math.PI * i) / size;
|
||||
table.cos[i] = Math.cos(angle);
|
||||
table.sin[i] = Math.sin(angle);
|
||||
}
|
||||
|
||||
this._twiddleCache.set(size, table);
|
||||
return table;
|
||||
},
|
||||
|
||||
fft(real, imaginary, inverse = false) {
|
||||
const size = real.length;
|
||||
this.reverseBits(real, imaginary);
|
||||
const { cos, sin } = this.twiddles(size);
|
||||
|
||||
for (let span = 2; span <= size; span <<= 1) {
|
||||
const half = span >> 1;
|
||||
const stride = size / span;
|
||||
|
||||
for (let base = 0; base < size; base += span) {
|
||||
for (let k = 0; k < half; k++) {
|
||||
const twiddle = k * stride;
|
||||
const wReal = cos[twiddle];
|
||||
const wImaginary = inverse ? -sin[twiddle] : sin[twiddle];
|
||||
|
||||
const lower = base + k;
|
||||
const upper = lower + half;
|
||||
const productReal = real[upper] * wReal - imaginary[upper] * wImaginary;
|
||||
const productImaginary = real[upper] * wImaginary + imaginary[upper] * wReal;
|
||||
|
||||
real[upper] = real[lower] - productReal;
|
||||
imaginary[upper] = imaginary[lower] - productImaginary;
|
||||
real[lower] += productReal;
|
||||
imaginary[lower] += productImaginary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inverse) return { real, imaginary };
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
real[i] /= size;
|
||||
imaginary[i] /= size;
|
||||
}
|
||||
|
||||
return { real, imaginary };
|
||||
},
|
||||
|
||||
reverseBits(real, imaginary) {
|
||||
const size = real.length;
|
||||
|
||||
for (let i = 1, j = 0; i < size; i++) {
|
||||
let bit = size >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i >= j) continue;
|
||||
|
||||
[real[i], real[j]] = [real[j], real[i]];
|
||||
[imaginary[i], imaginary[j]] = [imaginary[j], imaginary[i]];
|
||||
}
|
||||
|
||||
return { real, imaginary };
|
||||
},
|
||||
|
||||
/**
|
||||
* Matched-filter envelope of a recording against a template, via FFT cross-correlation.
|
||||
* Negative frequencies are dropped so the result is the analytic envelope rather than a burst
|
||||
* oscillating at the sweep frequency — an oscillating peak defeats sub-sample interpolation
|
||||
* and makes first-arrival detection jitter by half a carrier period.
|
||||
*/
|
||||
matchedFilterEnvelope(recording, template) {
|
||||
const size = this.nextPowerOfTwo(recording.length + template.length);
|
||||
const recordingReal = new Float64Array(size);
|
||||
const recordingImaginary = new Float64Array(size);
|
||||
const templateReal = new Float64Array(size);
|
||||
const templateImaginary = new Float64Array(size);
|
||||
|
||||
recordingReal.set(recording);
|
||||
templateReal.set(template);
|
||||
this.fft(recordingReal, recordingImaginary);
|
||||
this.fft(templateReal, templateImaginary);
|
||||
|
||||
const analyticReal = new Float64Array(size);
|
||||
const analyticImaginary = new Float64Array(size);
|
||||
const half = size >> 1;
|
||||
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const gain = i === 0 || i === half ? 1 : 2;
|
||||
analyticReal[i] = gain * (recordingReal[i] * templateReal[i] + recordingImaginary[i] * templateImaginary[i]);
|
||||
analyticImaginary[i] = gain * (recordingImaginary[i] * templateReal[i] - recordingReal[i] * templateImaginary[i]);
|
||||
}
|
||||
|
||||
this.fft(analyticReal, analyticImaginary, true);
|
||||
|
||||
const envelope = new Float32Array(recording.length);
|
||||
for (let i = 0; i < envelope.length; i++)
|
||||
envelope[i] = Math.sqrt(analyticReal[i] * analyticReal[i] + analyticImaginary[i] * analyticImaginary[i]);
|
||||
|
||||
return envelope;
|
||||
},
|
||||
|
||||
maxInRange(values, start, end) {
|
||||
let index = start;
|
||||
let value = -Infinity;
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
if (values[i] <= value) continue;
|
||||
value = values[i];
|
||||
index = i;
|
||||
}
|
||||
|
||||
return { index, value };
|
||||
},
|
||||
|
||||
medianInRange(values, start, end, sampleLimit = 2048) {
|
||||
const span = end - start;
|
||||
if (span <= 0) return 0;
|
||||
|
||||
const stride = Math.max(1, Math.floor(span / sampleLimit));
|
||||
const sampled = [];
|
||||
for (let i = start; i < end; i += stride) sampled.push(values[i]);
|
||||
|
||||
sampled.sort((left, right) => left - right);
|
||||
return sampled[sampled.length >> 1];
|
||||
},
|
||||
|
||||
/**
|
||||
* Sub-sample peak position by fitting a parabola through the peak and its neighbours. One
|
||||
* tenth of a sample is 0.7mm of range at 48kHz, so this is most of the accuracy for free.
|
||||
*/
|
||||
refinePeakIndex(envelope, index) {
|
||||
if (index <= 0 || index >= envelope.length - 1) return index;
|
||||
|
||||
const before = envelope[index - 1];
|
||||
const peak = envelope[index];
|
||||
const after = envelope[index + 1];
|
||||
const curvature = before - 2 * peak + after;
|
||||
if (curvature === 0) return index;
|
||||
|
||||
const offset = (0.5 * (before - after)) / curvature;
|
||||
return index + Math.max(-1, Math.min(1, offset));
|
||||
},
|
||||
|
||||
/**
|
||||
* First arrival in a window, not the loudest one. A reflection off a wall or table often
|
||||
* comes back louder than the direct path, and only the direct path is the distance.
|
||||
*/
|
||||
findFirstPeak(envelope, options = {}) {
|
||||
const start = Math.max(0, Math.floor(options.start ?? 0));
|
||||
const end = Math.min(envelope.length, Math.ceil(options.end ?? envelope.length));
|
||||
if (end - start < 8) return null;
|
||||
|
||||
const loudest = this.maxInRange(envelope, start, end);
|
||||
const noiseFloor = this.medianInRange(envelope, start, end);
|
||||
const snr = noiseFloor > 0 ? loudest.value / noiseFloor : Infinity;
|
||||
if (snr < (options.minSnr ?? 4)) return null;
|
||||
|
||||
// Held above the noise floor so sidelobes cannot trigger it, but well below the loudest
|
||||
// arrival so that a reflection several times louder than the direct path cannot mask it.
|
||||
const threshold = Math.max(
|
||||
noiseFloor * (options.noiseMultiple ?? 6),
|
||||
loudest.value * (options.relativeThreshold ?? 0.15)
|
||||
);
|
||||
|
||||
let crossing = start;
|
||||
while (crossing < end && envelope[crossing] < threshold) crossing++;
|
||||
if (crossing >= end) return null;
|
||||
|
||||
const lobeEnd = Math.min(end, crossing + (options.lobeSamples ?? 64));
|
||||
const arrival = this.maxInRange(envelope, crossing, lobeEnd);
|
||||
|
||||
return { index: this.refinePeakIndex(envelope, arrival.index), amplitude: arrival.value, snr };
|
||||
},
|
||||
|
||||
/**
|
||||
* Locate every chirp of one round in a single device's recording.
|
||||
*
|
||||
* The device's own chirp is the anchor: it is always present and always the loudest thing in
|
||||
* the recording, and its position absorbs this device's own output and input latency. Every
|
||||
* other slot is then searched relative to that anchor, so no clock agreement between devices
|
||||
* is required — only that the chirps stay in their slots.
|
||||
*/
|
||||
detectSlotPeaks({
|
||||
envelope,
|
||||
slotCount,
|
||||
slotSamples,
|
||||
ownSlot,
|
||||
ownSearchStart,
|
||||
ownSearchSamples,
|
||||
slotHints = null,
|
||||
peakOptions = {}
|
||||
}) {
|
||||
const own = this.findFirstPeak(envelope, {
|
||||
...peakOptions,
|
||||
start: ownSearchStart,
|
||||
end: ownSearchStart + ownSearchSamples
|
||||
});
|
||||
|
||||
if (!own) return null;
|
||||
|
||||
const anchor = own.index - ownSlot * slotSamples;
|
||||
const pad = Math.floor(slotSamples * 0.45);
|
||||
const peaks = new Array(slotCount).fill(null);
|
||||
peaks[ownSlot] = own;
|
||||
|
||||
for (let slot = 0; slot < slotCount; slot++) {
|
||||
if (slot === ownSlot) continue;
|
||||
|
||||
const centre = anchor + slot * slotSamples + (slotHints?.[slot] ?? 0);
|
||||
peaks[slot] = this.findFirstPeak(envelope, {
|
||||
...peakOptions,
|
||||
start: centre - pad,
|
||||
end: centre + pad
|
||||
});
|
||||
}
|
||||
|
||||
return { anchor, peaks };
|
||||
},
|
||||
|
||||
/**
|
||||
* Distance between two devices from four arrival indices, each measured inside the recording
|
||||
* of the device that made it. Clock offset and audio-pipeline latency appear once with each
|
||||
* sign and cancel; the devices' own speaker-to-microphone spacing does not, and is added back.
|
||||
*/
|
||||
pairDistance({ a1, a2, b1, b2, sampleRate, sampleRateA, sampleRateB, speedOfSound, epsilonA = 0, epsilonB = 0 }) {
|
||||
// Each interval is converted to seconds in its own device's sample rate before the two are
|
||||
// subtracted: a device that hands back 44100 instead of 48000 would otherwise contribute
|
||||
// its interval in the wrong unit.
|
||||
const intervalA = (a2 - a1) / (sampleRateA ?? sampleRate);
|
||||
const intervalB = (b2 - b1) / (sampleRateB ?? sampleRate);
|
||||
return ((intervalA - intervalB) / 2) * speedOfSound + (epsilonA + epsilonB) / 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* Symmetric distance matrix from one round of reports. Entries stay null where either device
|
||||
* failed to hear one of the four chirps the pair needs.
|
||||
*/
|
||||
buildDistanceMatrix(reports, { speedOfSound = 343, maxDistance = 40 } = {}) {
|
||||
const count = reports.length;
|
||||
const matrix = Array.from({ length: count }, () => new Array(count).fill(null));
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
matrix[i][i] = 0;
|
||||
|
||||
for (let j = i + 1; j < count; j++) {
|
||||
const distance = this.distanceBetween(reports[i], reports[j], speedOfSound);
|
||||
if (distance === null || distance < -1 || distance > maxDistance) continue;
|
||||
|
||||
matrix[i][j] = Math.max(0, distance);
|
||||
matrix[j][i] = matrix[i][j];
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
},
|
||||
|
||||
distanceBetween(deviceA, deviceB, speedOfSound) {
|
||||
const a1 = deviceA.peaks[deviceA.slot];
|
||||
const a2 = deviceA.peaks[deviceB.slot];
|
||||
const b1 = deviceB.peaks[deviceA.slot];
|
||||
const b2 = deviceB.peaks[deviceB.slot];
|
||||
if (a1 === null || a2 === null || b1 === null || b2 === null) return null;
|
||||
|
||||
return this.pairDistance({
|
||||
a1,
|
||||
a2,
|
||||
b1,
|
||||
b2,
|
||||
sampleRateA: deviceA.sampleRate,
|
||||
sampleRateB: deviceB.sampleRate,
|
||||
speedOfSound,
|
||||
epsilonA: deviceA.epsilon ?? 0,
|
||||
epsilonB: deviceB.epsilon ?? 0
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Largest set of devices linked by measured distances. Anything outside it cannot be placed
|
||||
* relative to the others, and leaving it in makes the completed matrix infinite.
|
||||
*/
|
||||
largestConnectedComponent(matrix) {
|
||||
const unvisited = new Set(matrix.map((_, index) => index));
|
||||
let largest = [];
|
||||
|
||||
while (unvisited.size > 0) {
|
||||
const component = [];
|
||||
const queue = [unvisited.values().next().value];
|
||||
unvisited.delete(queue[0]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop();
|
||||
component.push(current);
|
||||
|
||||
for (const next of unvisited)
|
||||
if (matrix[current][next] !== null) {
|
||||
unvisited.delete(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (component.length > largest.length) largest = component;
|
||||
}
|
||||
|
||||
return largest.sort((left, right) => left - right);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop devices whose distances are geometrically impossible. One device reporting a bad peak
|
||||
* distorts the whole layout, so the worst triangle-inequality offender is removed and the
|
||||
* check repeated. Below four devices there is no redundancy left and nothing can be checked.
|
||||
*/
|
||||
rejectOutliers(matrix, candidates, tolerance = 0.5) {
|
||||
const keep = [...candidates];
|
||||
|
||||
while (keep.length > 3) {
|
||||
const violations = this.countTriangleViolations(matrix, keep, tolerance);
|
||||
const worst = violations.reduce((best, count, index) => (count > violations[best] ? index : best), 0);
|
||||
if (violations[worst] === 0) break;
|
||||
|
||||
keep.splice(worst, 1);
|
||||
}
|
||||
|
||||
return keep;
|
||||
},
|
||||
|
||||
submatrix(matrix, indices) {
|
||||
return indices.map(row => indices.map(column => matrix[row][column]));
|
||||
},
|
||||
|
||||
countTriangleViolations(matrix, keep, tolerance) {
|
||||
const violations = new Array(keep.length).fill(0);
|
||||
|
||||
for (let i = 0; i < keep.length; i++) {
|
||||
for (let j = i + 1; j < keep.length; j++) {
|
||||
for (let k = j + 1; k < keep.length; k++) {
|
||||
const sides = [matrix[keep[i]][keep[j]], matrix[keep[j]][keep[k]], matrix[keep[i]][keep[k]]];
|
||||
if (sides.some(side => side === null)) continue;
|
||||
|
||||
const longest = Math.max(...sides);
|
||||
const perimeter = sides.reduce((sum, side) => sum + side, 0);
|
||||
if (longest <= perimeter - longest + tolerance) continue;
|
||||
|
||||
violations[i]++;
|
||||
violations[j]++;
|
||||
violations[k]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
},
|
||||
|
||||
/** Fill gaps with the shortest known path between the two devices so MDS gets a full matrix. */
|
||||
completeMatrix(matrix) {
|
||||
const count = matrix.length;
|
||||
const filled = matrix.map(row => row.map(value => (value === null ? Infinity : value)));
|
||||
|
||||
for (let via = 0; via < count; via++)
|
||||
for (let i = 0; i < count; i++)
|
||||
for (let j = 0; j < count; j++)
|
||||
filled[i][j] = Math.min(filled[i][j], filled[i][via] + filled[via][j]);
|
||||
|
||||
return filled;
|
||||
},
|
||||
|
||||
/** Jacobi eigendecomposition of a symmetric matrix. Returns eigenvalues and column vectors. */
|
||||
symmetricEigen(matrix, maxSweeps = 100, tolerance = 1e-14) {
|
||||
const count = matrix.length;
|
||||
const working = matrix.map(row => Float64Array.from(row));
|
||||
const vectors = Array.from({ length: count }, (_, i) => {
|
||||
const column = new Float64Array(count);
|
||||
column[i] = 1;
|
||||
return column;
|
||||
});
|
||||
|
||||
for (let sweep = 0; sweep < maxSweeps; sweep++) {
|
||||
if (this.offDiagonalMagnitude(working) < tolerance) break;
|
||||
|
||||
for (let p = 0; p < count - 1; p++)
|
||||
for (let q = p + 1; q < count; q++)
|
||||
this.rotateOut(working, vectors, p, q);
|
||||
}
|
||||
|
||||
return {
|
||||
values: working.map((row, i) => row[i]),
|
||||
vectors
|
||||
};
|
||||
},
|
||||
|
||||
offDiagonalMagnitude(matrix) {
|
||||
let total = 0;
|
||||
|
||||
for (let i = 0; i < matrix.length; i++)
|
||||
for (let j = i + 1; j < matrix.length; j++) total += matrix[i][j] * matrix[i][j];
|
||||
|
||||
return total;
|
||||
},
|
||||
|
||||
rotateOut(matrix, vectors, p, q) {
|
||||
if (Math.abs(matrix[p][q]) < 1e-300) return matrix;
|
||||
|
||||
const theta = (matrix[q][q] - matrix[p][p]) / (2 * matrix[p][q]);
|
||||
const sign = theta >= 0 ? 1 : -1;
|
||||
const tangent = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
||||
const cosine = 1 / Math.sqrt(tangent * tangent + 1);
|
||||
const sine = tangent * cosine;
|
||||
const count = matrix.length;
|
||||
|
||||
for (let k = 0; k < count; k++) {
|
||||
const left = matrix[k][p];
|
||||
const right = matrix[k][q];
|
||||
matrix[k][p] = cosine * left - sine * right;
|
||||
matrix[k][q] = sine * left + cosine * right;
|
||||
}
|
||||
|
||||
for (let k = 0; k < count; k++) {
|
||||
const left = matrix[p][k];
|
||||
const right = matrix[q][k];
|
||||
matrix[p][k] = cosine * left - sine * right;
|
||||
matrix[q][k] = sine * left + cosine * right;
|
||||
}
|
||||
|
||||
for (let k = 0; k < count; k++) {
|
||||
const left = vectors[k][p];
|
||||
const right = vectors[k][q];
|
||||
vectors[k][p] = cosine * left - sine * right;
|
||||
vectors[k][q] = sine * left + cosine * right;
|
||||
}
|
||||
|
||||
return matrix;
|
||||
},
|
||||
|
||||
/**
|
||||
* Classical multidimensional scaling: coordinates whose pairwise distances best reproduce the
|
||||
* matrix. The result is only defined up to rotation, translation and mirroring.
|
||||
*/
|
||||
classicalMds(distances, dimensions = 2) {
|
||||
const count = distances.length;
|
||||
const squared = distances.map(row => row.map(value => value * value));
|
||||
const rowMeans = squared.map(row => row.reduce((sum, value) => sum + value, 0) / count);
|
||||
const grandMean = rowMeans.reduce((sum, value) => sum + value, 0) / count;
|
||||
|
||||
const centred = squared.map((row, i) => row.map((value, j) => -0.5 * (value - rowMeans[i] - rowMeans[j] + grandMean)));
|
||||
const { values, vectors } = this.symmetricEigen(centred);
|
||||
const order = values
|
||||
.map((value, index) => ({ value, index }))
|
||||
.sort((left, right) => right.value - left.value)
|
||||
.slice(0, dimensions);
|
||||
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
order.map(({ value, index }) => vectors[i][index] * Math.sqrt(Math.max(0, value)))
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Rotate, mirror and translate a constellation onto a reference layout. Without this, every
|
||||
* solve returns an arbitrary orientation and the display spins and flips between updates.
|
||||
*/
|
||||
alignToReference(points, reference) {
|
||||
if (!reference || reference.length !== points.length || points.length === 0) return points;
|
||||
|
||||
const pointCentre = this.centroid(points);
|
||||
const referenceCentre = this.centroid(reference);
|
||||
let best = null;
|
||||
|
||||
for (const mirror of [1, -1]) {
|
||||
const candidate = this.rotateOnto(points, reference, pointCentre, referenceCentre, mirror);
|
||||
if (!best || candidate.residual < best.residual) best = candidate;
|
||||
}
|
||||
|
||||
return best.points;
|
||||
},
|
||||
|
||||
centroid(points) {
|
||||
const sum = points.reduce((total, [x, y]) => [total[0] + x, total[1] + y], [0, 0]);
|
||||
return [sum[0] / points.length, sum[1] / points.length];
|
||||
},
|
||||
|
||||
rotateOnto(points, reference, pointCentre, referenceCentre, mirror) {
|
||||
let sineTerm = 0;
|
||||
let cosineTerm = 0;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const px = (points[i][0] - pointCentre[0]) * mirror;
|
||||
const py = points[i][1] - pointCentre[1];
|
||||
const qx = reference[i][0] - referenceCentre[0];
|
||||
const qy = reference[i][1] - referenceCentre[1];
|
||||
sineTerm += px * qy - py * qx;
|
||||
cosineTerm += px * qx + py * qy;
|
||||
}
|
||||
|
||||
const angle = Math.atan2(sineTerm, cosineTerm);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
let residual = 0;
|
||||
|
||||
const aligned = points.map((point, i) => {
|
||||
const px = (point[0] - pointCentre[0]) * mirror;
|
||||
const py = point[1] - pointCentre[1];
|
||||
const x = px * cosine - py * sine + referenceCentre[0];
|
||||
const y = px * sine + py * cosine + referenceCentre[1];
|
||||
residual += (x - reference[i][0]) ** 2 + (y - reference[i][1]) ** 2;
|
||||
return [x, y];
|
||||
});
|
||||
|
||||
return { points: aligned, residual };
|
||||
},
|
||||
|
||||
/**
|
||||
* Full solve for one round: distances, connectivity, outlier rejection, then a constellation
|
||||
* aligned onto the previous frame.
|
||||
*
|
||||
* <c>previousPoints</c> is indexed by report position, with null for devices that were dropped
|
||||
* last round, so alignment survives devices coming and going.
|
||||
*/
|
||||
solveRound(reports, { speedOfSound = 343, previousPoints = null, tolerance = 0.5 } = {}) {
|
||||
const matrix = this.buildDistanceMatrix(reports, { speedOfSound });
|
||||
const connected = this.largestConnectedComponent(matrix);
|
||||
const keep = this.rejectOutliers(matrix, connected, tolerance);
|
||||
const points = keep.length >= 2 ? this.classicalMds(this.completeMatrix(this.submatrix(matrix, keep))) : [];
|
||||
const reference = previousPoints ? keep.map(index => previousPoints[index]) : null;
|
||||
const alignable = reference?.length === points.length && reference.every(Boolean);
|
||||
|
||||
return {
|
||||
matrix,
|
||||
keep,
|
||||
points: alignable ? this.alignToReference(points, reference) : points
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") window.EchoDsp = EchoDsp;
|
||||
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
* Round lifecycle: join a room, chirp when told to, measure every chirp in our own recording, and
|
||||
* report the frame indices. Peaks are reported relative to the analysis window rather than to an
|
||||
* absolute frame — every measurement is a difference within one device's own recording, so a
|
||||
* constant per-device origin cancels.
|
||||
*
|
||||
* Both the chirp and the analysis are driven by the audio clock rather than by timers: a hidden tab
|
||||
* has its timers throttled to roughly once a second, which is long enough to put its chirp in
|
||||
* another device's slot and turn the whole round into nonsense.
|
||||
*/
|
||||
const EchoSession = {
|
||||
LEAD_IN_SECONDS: 0.25,
|
||||
TEMPERATURE_CELSIUS: 20,
|
||||
MAX_OUTPUT_LATENCY_SECONDS: 0.35,
|
||||
CHIRP: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
|
||||
|
||||
CLOCK_SAMPLES: 5,
|
||||
LATE_TOLERANCE_SECONDS: 0.05,
|
||||
|
||||
connection: null,
|
||||
deviceId: null,
|
||||
roomCode: null,
|
||||
serverOffsetMs: 0,
|
||||
skippedRounds: 0,
|
||||
devices: [],
|
||||
chirp: null,
|
||||
pending: null,
|
||||
lastRound: null,
|
||||
hintsByDevice: {},
|
||||
previousPoints: null,
|
||||
epsilon: 0.08,
|
||||
onUpdate: () => {},
|
||||
|
||||
async join(roomCode, displayName, { onUpdate }) {
|
||||
this.onUpdate = onUpdate ?? this.onUpdate;
|
||||
const audio = await EchoAudio.start();
|
||||
this.chirp = EchoDsp.makeChirp({ sampleRate: audio.sampleRate, ...this.CHIRP });
|
||||
EchoAudio.onSamples = frame => this.analyseIfCaptured(frame);
|
||||
|
||||
this.connection = new signalR.HubConnectionBuilder().withUrl("/echoHub").withAutomaticReconnect().build();
|
||||
this.connection.on("RoomChanged", room => this.handleRoomChanged(room));
|
||||
this.connection.on("RoundStarting", schedule => this.handleRoundStarting(schedule));
|
||||
this.connection.on("RoundComplete", result => this.handleRoundComplete(result));
|
||||
await this.connection.start();
|
||||
|
||||
const joined = await this.connection.invoke("JoinRoom", roomCode, displayName, audio.sampleRate);
|
||||
this.deviceId = joined.deviceId;
|
||||
this.roomCode = joined.roomCode;
|
||||
this.devices = joined.room.devices;
|
||||
this.serverOffsetMs = await this.estimateServerOffset();
|
||||
|
||||
return { ...joined, audio, serverOffsetMs: this.serverOffsetMs };
|
||||
},
|
||||
|
||||
/**
|
||||
* Offset from the server clock, by the usual round-trip midpoint. Only needs to be good enough
|
||||
* to identify which chirp belongs to which slot, which is tens of milliseconds — the ranging
|
||||
* itself never uses a shared clock.
|
||||
*/
|
||||
async estimateServerOffset() {
|
||||
const offsets = [];
|
||||
|
||||
for (let attempt = 0; attempt < this.CLOCK_SAMPLES; attempt++) {
|
||||
const sent = Date.now();
|
||||
const serverNow = await this.connection.invoke("ServerTime");
|
||||
const received = Date.now();
|
||||
offsets.push(serverNow - (sent + received) / 2);
|
||||
}
|
||||
|
||||
offsets.sort((left, right) => left - right);
|
||||
return offsets[offsets.length >> 1];
|
||||
},
|
||||
|
||||
serverNowMs() {
|
||||
return Date.now() + this.serverOffsetMs;
|
||||
},
|
||||
|
||||
handleRoomChanged(room) {
|
||||
this.devices = room.devices;
|
||||
this.previousPoints = null;
|
||||
this.hintsByDevice = {};
|
||||
this.onUpdate({ kind: "room", room });
|
||||
return room;
|
||||
},
|
||||
|
||||
/**
|
||||
* Schedule our chirp for our slot on the audio clock, and record which frames this round will
|
||||
* occupy so the analysis can start the moment that audio has actually been captured.
|
||||
*/
|
||||
handleRoundStarting(schedule) {
|
||||
const ownSlot = schedule.slotOrder.indexOf(this.deviceId);
|
||||
if (ownSlot < 0 || !EchoAudio.context) return null;
|
||||
|
||||
const sampleRate = EchoAudio.context.sampleRate;
|
||||
const slotSeconds = schedule.slotMilliseconds / 1000;
|
||||
const secondsUntilStart = (schedule.startsAtUnixMs - this.serverNowMs()) / 1000;
|
||||
const roundStartTime = EchoAudio.context.currentTime + secondsUntilStart;
|
||||
const playAtTime = roundStartTime + ownSlot * slotSeconds;
|
||||
|
||||
// Sitting a round out is far better than chirping late: a chirp in the wrong slot is
|
||||
// attributed to the wrong device and ruins the round for everyone, whereas a missing chirp
|
||||
// costs only this pair, this round.
|
||||
if (playAtTime < EchoAudio.context.currentTime - this.LATE_TOLERANCE_SECONDS) {
|
||||
this.skippedRounds++;
|
||||
this.onUpdate({ kind: "skipped", schedule, lateBySeconds: EchoAudio.context.currentTime - playAtTime });
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduledFrame = EchoAudio.play(this.chirp, playAtTime);
|
||||
|
||||
const round = {
|
||||
schedule,
|
||||
ownSlot,
|
||||
sampleRate,
|
||||
scheduledFrame,
|
||||
slotSamples: Math.round(slotSeconds * sampleRate),
|
||||
leadInSamples: Math.round(this.LEAD_IN_SECONDS * sampleRate),
|
||||
startFrame: EchoAudio.frameAt(roundStartTime),
|
||||
outputLatencyMs: Math.round(EchoAudio.outputLatencySeconds() * 1000),
|
||||
captureLagMs: Math.round(EchoAudio.captureLagSeconds() * 1000)
|
||||
};
|
||||
|
||||
round.windowStart = round.startFrame - round.leadInSamples;
|
||||
round.windowLength =
|
||||
round.leadInSamples +
|
||||
schedule.slotOrder.length * round.slotSamples +
|
||||
Math.round((schedule.tailMilliseconds / 1000) * sampleRate);
|
||||
|
||||
this.pending = round;
|
||||
this.onUpdate({ kind: "roundStarting", round });
|
||||
return round;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run as soon as the round's audio exists in the ring. Driven by captured audio rather than a
|
||||
* timer so a throttled tab still analyses on time.
|
||||
*/
|
||||
analyseIfCaptured(highestFrame) {
|
||||
const round = this.pending;
|
||||
if (!round) return null;
|
||||
if (highestFrame < round.windowStart + round.windowLength) return null;
|
||||
|
||||
this.pending = null;
|
||||
return this.analyse(round);
|
||||
},
|
||||
|
||||
analyse(round) {
|
||||
this.lastRound = round;
|
||||
const recording = EchoAudio.read(round.windowStart, round.windowLength);
|
||||
if (!recording) return null;
|
||||
|
||||
const envelope = EchoDsp.matchedFilterEnvelope(recording, this.chirp);
|
||||
const detected = EchoDsp.detectSlotPeaks({
|
||||
envelope,
|
||||
slotCount: round.schedule.slotOrder.length,
|
||||
slotSamples: round.slotSamples,
|
||||
ownSlot: round.ownSlot,
|
||||
// Anchored on the frame the chirp was scheduled for, so the only unknown left is how
|
||||
// long the speaker takes to actually emit it.
|
||||
ownSearchStart: round.scheduledFrame - round.windowStart,
|
||||
ownSearchSamples: Math.round((this.MAX_OUTPUT_LATENCY_SECONDS + this.CHIRP.durationSeconds) * round.sampleRate),
|
||||
slotHints: this.hintsFor(round.schedule)
|
||||
});
|
||||
|
||||
const diagnostics = this.describeRound(round, detected);
|
||||
this.onUpdate({ kind: "envelope", envelope, detected, round, diagnostics });
|
||||
if (!detected) return null;
|
||||
|
||||
this.rememberHints(round.schedule, detected, round.slotSamples);
|
||||
return this.report(round, detected);
|
||||
},
|
||||
|
||||
describeRound(round, detected) {
|
||||
const millisecondsPer = 1000 / round.sampleRate;
|
||||
|
||||
return {
|
||||
captureLagMs: round.captureLagMs,
|
||||
outputLatencyMs: round.outputLatencyMs,
|
||||
serverOffsetMs: Math.round(this.serverOffsetMs),
|
||||
skippedRounds: this.skippedRounds,
|
||||
clipping: EchoAudio.peakAmplitude >= 0.99,
|
||||
inputPeak: Number(EchoAudio.peakAmplitude.toFixed(3)),
|
||||
ownFound: Boolean(detected?.peaks[round.ownSlot]),
|
||||
slots: round.schedule.slotOrder.map((deviceId, slot) => ({
|
||||
deviceId,
|
||||
own: slot === round.ownSlot,
|
||||
residualMs: detected?.peaks[slot]
|
||||
? Math.round((detected.peaks[slot].index - (detected.anchor + slot * round.slotSamples)) * millisecondsPer)
|
||||
: null,
|
||||
snr: detected?.peaks[slot] ? Math.round(detected.peaks[slot].snr) : null
|
||||
}))
|
||||
};
|
||||
},
|
||||
|
||||
report(round, detected) {
|
||||
return this.connection.invoke("ReportRound", {
|
||||
deviceId: this.deviceId,
|
||||
roundId: round.schedule.roundId,
|
||||
slot: round.ownSlot,
|
||||
sampleRate: round.sampleRate,
|
||||
epsilon: this.epsilon,
|
||||
peaks: detected.peaks.map(peak => peak?.index ?? null)
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Where each device's chirp actually landed last round, relative to where the slot said it
|
||||
* would. Output latency differs by tens of milliseconds per device and is stable, so carrying
|
||||
* the residual forward keeps the search windows centred instead of merely wide.
|
||||
*/
|
||||
rememberHints(schedule, detected, slotSamples) {
|
||||
schedule.slotOrder.forEach((deviceId, slot) => {
|
||||
const peak = detected.peaks[slot];
|
||||
if (!peak) return;
|
||||
this.hintsByDevice[deviceId] = peak.index - (detected.anchor + slot * slotSamples);
|
||||
});
|
||||
|
||||
return this.hintsByDevice;
|
||||
},
|
||||
|
||||
hintsFor(schedule) {
|
||||
return schedule.slotOrder.map(deviceId => this.hintsByDevice[deviceId] ?? 0);
|
||||
},
|
||||
|
||||
handleRoundComplete(result) {
|
||||
const reports = result.reports.map(report => ({ ...report, peaks: report.peaks ?? [] }));
|
||||
const solved = EchoDsp.solveRound(reports, {
|
||||
speedOfSound: EchoDsp.speedOfSound(this.TEMPERATURE_CELSIUS),
|
||||
previousPoints: this.previousPoints
|
||||
});
|
||||
|
||||
this.previousPoints = this.pointsByReportIndex(reports, solved);
|
||||
this.onUpdate({ kind: "solved", result, reports, solved, deviceId: this.deviceId });
|
||||
return solved;
|
||||
},
|
||||
|
||||
pointsByReportIndex(reports, solved) {
|
||||
const points = new Array(reports.length).fill(null);
|
||||
solved.keep.forEach((reportIndex, i) => {
|
||||
points[reportIndex] = solved.points[i];
|
||||
});
|
||||
return points;
|
||||
},
|
||||
|
||||
setEpsilon(metres) {
|
||||
this.epsilon = metres;
|
||||
localStorage.setItem("echo.epsilon", String(metres));
|
||||
return this.epsilon;
|
||||
},
|
||||
|
||||
loadEpsilon() {
|
||||
const stored = Number(localStorage.getItem("echo.epsilon"));
|
||||
this.epsilon = Number.isFinite(stored) && stored > 0 ? stored : 0.08;
|
||||
return this.epsilon;
|
||||
},
|
||||
|
||||
async leave() {
|
||||
await this.connection?.invoke("LeaveRoom").catch(() => {});
|
||||
await this.connection?.stop();
|
||||
await EchoAudio.stop();
|
||||
this.connection = null;
|
||||
this.pending = null;
|
||||
this.previousPoints = null;
|
||||
this.hintsByDevice = {};
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") window.EchoSession = EchoSession;
|
||||
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* Virtual room for exercising the real ranging pipeline without microphones. Synthesizes what
|
||||
* each device would have recorded — propagation delay, reflections, noise, per-device clock offset
|
||||
* and unknown output latency — then runs the same EchoDsp code the page runs.
|
||||
*/
|
||||
const EchoSim = {
|
||||
DEFAULTS: {
|
||||
sampleRate: 48000,
|
||||
slotSeconds: 0.4,
|
||||
tailSeconds: 0.5,
|
||||
speedOfSound: 343,
|
||||
noiseAmplitude: 0.01,
|
||||
referenceGain: 0.5,
|
||||
minimumPathMetres: 0.25,
|
||||
maximumOutputLatencySeconds: 0.2,
|
||||
chirp: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
|
||||
reflectionsPerPath: 2,
|
||||
reflectionExtraRange: [0.4, 4.0],
|
||||
reflectionGainRange: [0.2, 0.8],
|
||||
seed: 20260730
|
||||
},
|
||||
|
||||
randomGenerator(seed) {
|
||||
let state = seed >>> 0;
|
||||
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let mixed = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed;
|
||||
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
},
|
||||
|
||||
separation(first, second) {
|
||||
return Math.hypot(first[0] - second[0], first[1] - second[1]);
|
||||
},
|
||||
|
||||
buildConfiguration(overrides = {}) {
|
||||
const config = { ...this.DEFAULTS, ...overrides };
|
||||
const count = config.positions.length;
|
||||
|
||||
config.chirp = { ...this.DEFAULTS.chirp, ...(overrides.chirp ?? {}) };
|
||||
config.epsilon ??= new Array(count).fill(0.05);
|
||||
config.clockOffsets ??= config.positions.map((_, i) => i * 7919);
|
||||
config.outputLatencies ??= config.positions.map((_, i) => 0.02 + 0.03 * i);
|
||||
config.scheduleJitter ??= config.positions.map((_, i) => 0.004 * i);
|
||||
config.reflections ??= this.buildReflectionTable(config);
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Multipath for every source-to-listener path independently. Giving every path the same echo
|
||||
* would be worthless as a test: an identical bias on all four arrivals cancels out of the
|
||||
* range formula, so a uniform echo model hides exactly the error it is supposed to expose.
|
||||
*/
|
||||
buildReflectionTable(config) {
|
||||
const random = this.randomGenerator(config.seed ^ 0x5f3759df);
|
||||
const spread = (range, value) => range[0] + value * (range[1] - range[0]);
|
||||
|
||||
return config.positions.map(() =>
|
||||
config.positions.map(() =>
|
||||
Array.from({ length: config.reflectionsPerPath }, () => ({
|
||||
extraMetres: spread(config.reflectionExtraRange, random()),
|
||||
gain: spread(config.reflectionGainRange, random())
|
||||
}))
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
reflectionsFor(config, source, listener) {
|
||||
return Array.isArray(config.reflections[0]) ? config.reflections[source][listener] : config.reflections;
|
||||
},
|
||||
|
||||
/** One recording per device, plus the index each device believes it started playing at. */
|
||||
synthesizeRound(config) {
|
||||
const { positions, sampleRate, slotSeconds, tailSeconds } = config;
|
||||
const random = this.randomGenerator(config.seed);
|
||||
const template = EchoDsp.makeChirp({ sampleRate, ...config.chirp });
|
||||
const maximumOffset = Math.max(...config.clockOffsets);
|
||||
const length = Math.ceil((positions.length * slotSeconds + tailSeconds) * sampleRate) + maximumOffset;
|
||||
|
||||
const devices = positions.map((_, index) => ({
|
||||
recording: this.noiseBuffer(length, config.noiseAmplitude, random),
|
||||
ownSearchStart: Math.round((index * slotSeconds + config.scheduleJitter[index]) * sampleRate) + config.clockOffsets[index]
|
||||
}));
|
||||
|
||||
for (let source = 0; source < positions.length; source++)
|
||||
for (let listener = 0; listener < positions.length; listener++)
|
||||
this.mixArrivals(devices[listener].recording, template, config, source, listener);
|
||||
|
||||
return { devices, template };
|
||||
},
|
||||
|
||||
noiseBuffer(length, amplitude, random) {
|
||||
const buffer = new Float32Array(length);
|
||||
for (let i = 0; i < length; i++) buffer[i] = (random() * 2 - 1) * amplitude;
|
||||
return buffer;
|
||||
},
|
||||
|
||||
mixArrivals(recording, template, config, source, listener) {
|
||||
const emissionSeconds =
|
||||
source * config.slotSeconds + config.scheduleJitter[source] + config.outputLatencies[source];
|
||||
const directMetres =
|
||||
source === listener ? config.epsilon[source] : this.separation(config.positions[source], config.positions[listener]);
|
||||
|
||||
const paths = [
|
||||
{ metres: directMetres, gain: 1 },
|
||||
...this.reflectionsFor(config, source, listener).map(({ extraMetres, gain }) => ({
|
||||
metres: directMetres + extraMetres,
|
||||
gain
|
||||
}))
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
const arrival = emissionSeconds + path.metres / config.speedOfSound;
|
||||
const amplitude =
|
||||
(config.referenceGain / Math.max(path.metres, config.minimumPathMetres)) * path.gain;
|
||||
this.addAt(recording, template, Math.round(arrival * config.sampleRate) + config.clockOffsets[listener], amplitude);
|
||||
}
|
||||
|
||||
return recording;
|
||||
},
|
||||
|
||||
addAt(recording, template, offset, amplitude) {
|
||||
const start = Math.max(0, offset);
|
||||
const end = Math.min(recording.length, offset + template.length);
|
||||
|
||||
for (let i = start; i < end; i++) recording[i] += template[i - offset] * amplitude;
|
||||
|
||||
return recording;
|
||||
},
|
||||
|
||||
/** Run every device's recording through detection and return one report per device. */
|
||||
detectAll({ devices, template }, config) {
|
||||
const slotSamples = Math.round(config.slotSeconds * config.sampleRate);
|
||||
const searchSamples = Math.round(
|
||||
(config.maximumOutputLatencySeconds + config.chirp.durationSeconds + 0.05) * config.sampleRate
|
||||
);
|
||||
|
||||
return devices.map((device, slot) => {
|
||||
const envelope = EchoDsp.matchedFilterEnvelope(device.recording, template);
|
||||
const detected = EchoDsp.detectSlotPeaks({
|
||||
envelope,
|
||||
slotCount: devices.length,
|
||||
slotSamples,
|
||||
ownSlot: slot,
|
||||
ownSearchStart: device.ownSearchStart,
|
||||
ownSearchSamples: searchSamples,
|
||||
peakOptions: config.peakOptions ?? {}
|
||||
});
|
||||
|
||||
return {
|
||||
deviceId: `sim-${slot}`,
|
||||
slot,
|
||||
sampleRate: config.sampleRate,
|
||||
epsilon: config.epsilon[slot],
|
||||
peaks: (detected?.peaks ?? new Array(devices.length).fill(null)).map(peak => peak?.index ?? null)
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/** Synthesize, detect and solve, reporting recovered geometry against the ground truth. */
|
||||
runRound(overrides = {}) {
|
||||
const config = this.buildConfiguration(overrides);
|
||||
const round = this.synthesizeRound(config);
|
||||
const reports = this.detectAll(round, config);
|
||||
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
|
||||
|
||||
return {
|
||||
config,
|
||||
reports,
|
||||
...solved,
|
||||
distanceErrors: this.distanceErrors(solved.matrix, config),
|
||||
positionErrors: this.positionErrors(solved, config)
|
||||
};
|
||||
},
|
||||
|
||||
distanceErrors(matrix, config) {
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < matrix.length; i++)
|
||||
for (let j = i + 1; j < matrix.length; j++) {
|
||||
const truth = this.separation(config.positions[i], config.positions[j]);
|
||||
errors.push({
|
||||
pair: [i, j],
|
||||
truth,
|
||||
measured: matrix[i][j],
|
||||
error: matrix[i][j] === null ? null : matrix[i][j] - truth
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
|
||||
positionErrors({ keep, points }, config) {
|
||||
if (points.length !== keep.length || points.length < 2) return [];
|
||||
|
||||
const truth = keep.map(index => config.positions[index]);
|
||||
const aligned = EchoDsp.alignToReference(points, truth);
|
||||
return aligned.map((point, i) => this.separation(point, truth[i]));
|
||||
},
|
||||
|
||||
worstDistanceError(result) {
|
||||
const magnitudes = result.distanceErrors.map(({ error }) => (error === null ? Infinity : Math.abs(error)));
|
||||
return magnitudes.length === 0 ? 0 : Math.max(...magnitudes);
|
||||
},
|
||||
|
||||
worstPositionError(result) {
|
||||
return result.positionErrors.length === 0 ? Infinity : Math.max(...result.positionErrors);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") window.EchoSim = EchoSim;
|
||||
@@ -1,282 +0,0 @@
|
||||
/*
|
||||
* Page wiring for /echo. Renders what the session measures: the matched-filter trace with the
|
||||
* arrivals it picked, the pairwise ranges, and — while only two devices are present — one large
|
||||
* number, because that number is the whole measurement.
|
||||
*/
|
||||
const EchoPage = {
|
||||
HISTORY_LENGTH: 12,
|
||||
|
||||
elements: {},
|
||||
lastSolved: null,
|
||||
lastDiagnostics: null,
|
||||
history: [],
|
||||
roundsSeen: 0,
|
||||
|
||||
start() {
|
||||
this.elements = {
|
||||
room: document.getElementById("echoRoom"),
|
||||
name: document.getElementById("echoName"),
|
||||
join: document.getElementById("echoJoin"),
|
||||
leave: document.getElementById("echoLeave"),
|
||||
status: document.getElementById("echoStatus"),
|
||||
warnings: document.getElementById("echoWarnings"),
|
||||
readout: document.getElementById("echoReadout"),
|
||||
pairs: document.getElementById("echoPairs"),
|
||||
roster: document.getElementById("echoRoster"),
|
||||
trace: document.getElementById("echoTrace"),
|
||||
epsilon: document.getElementById("echoEpsilon"),
|
||||
shareLink: document.getElementById("echoShareLink"),
|
||||
diagnostics: document.getElementById("echoDiagnostics")
|
||||
};
|
||||
|
||||
this.elements.room.value = new URLSearchParams(location.search).get("room") ?? this.randomCode();
|
||||
this.elements.epsilon.value = EchoSession.loadEpsilon();
|
||||
this.elements.epsilon.addEventListener("change", () => this.applyEpsilon());
|
||||
this.elements.join.addEventListener("click", () => this.join());
|
||||
this.elements.leave.addEventListener("click", () => this.leave());
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
randomCode() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
return Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
|
||||
},
|
||||
|
||||
applyEpsilon() {
|
||||
const metres = Number(this.elements.epsilon.value);
|
||||
if (!Number.isFinite(metres) || metres < 0) return null;
|
||||
|
||||
EchoSession.setEpsilon(metres);
|
||||
return metres;
|
||||
},
|
||||
|
||||
async join() {
|
||||
const roomCode = this.elements.room.value.trim().toUpperCase();
|
||||
if (!roomCode) return null;
|
||||
|
||||
this.setStatus("Requesting the microphone…");
|
||||
this.elements.join.disabled = true;
|
||||
|
||||
try {
|
||||
const joined = await EchoSession.join(roomCode, this.deviceName(), { onUpdate: update => this.handle(update) });
|
||||
this.showJoined(joined);
|
||||
return joined;
|
||||
} catch (error) {
|
||||
this.setStatus(`Could not start: ${error.message}`);
|
||||
this.elements.join.disabled = false;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deviceName() {
|
||||
const typed = this.elements.name.value.trim();
|
||||
if (typed) return typed;
|
||||
|
||||
return /android|iphone|ipad|mobile/i.test(navigator.userAgent) ? "phone" : "laptop";
|
||||
},
|
||||
|
||||
showJoined(joined) {
|
||||
this.elements.leave.hidden = false;
|
||||
this.elements.room.disabled = true;
|
||||
this.elements.name.disabled = true;
|
||||
this.elements.shareLink.textContent = `${location.origin}/echo?room=${joined.roomCode}`;
|
||||
this.elements.shareLink.href = `/echo?room=${joined.roomCode}`;
|
||||
this.renderWarnings(joined.audio);
|
||||
this.renderRoster(joined.room.devices);
|
||||
this.setStatus(`Listening at ${joined.audio.sampleRate}Hz. Open the same room on another device.`);
|
||||
return joined;
|
||||
},
|
||||
|
||||
async leave() {
|
||||
await EchoSession.leave();
|
||||
this.elements.leave.hidden = true;
|
||||
this.elements.join.disabled = false;
|
||||
this.elements.room.disabled = false;
|
||||
this.elements.name.disabled = false;
|
||||
this.setStatus("Left the room.");
|
||||
return true;
|
||||
},
|
||||
|
||||
handle(update) {
|
||||
if (update.kind === "room") return this.renderRoster(update.room.devices);
|
||||
if (update.kind === "envelope") {
|
||||
this.renderDiagnostics(update.diagnostics);
|
||||
return this.renderTrace(update);
|
||||
}
|
||||
if (update.kind === "solved") return this.renderSolved(update);
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-slot residual is the diagnostic that matters: it is how far each chirp landed from where
|
||||
* its slot said it would. Steady residuals mean the arrivals are being attributed correctly;
|
||||
* residuals jumping by more than a slot mean they are not, and every range is then meaningless.
|
||||
*/
|
||||
renderDiagnostics(diagnostics) {
|
||||
if (!diagnostics) return null;
|
||||
this.lastDiagnostics = diagnostics;
|
||||
|
||||
const slots = diagnostics.slots
|
||||
.map(slot => {
|
||||
const label = slot.own ? "self" : slot.deviceId;
|
||||
const residual = slot.residualMs === null ? "missed" : `${slot.residualMs > 0 ? "+" : ""}${slot.residualMs}ms`;
|
||||
return `<li class="${slot.residualMs === null ? "echo-missed" : ""}">${label} · ${residual}${slot.snr === null ? "" : ` · snr ${slot.snr}`}</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
this.elements.diagnostics.innerHTML = `
|
||||
<ul class="echo-slots">${slots}</ul>
|
||||
<p class="echo-diag-line">
|
||||
capture lag ${diagnostics.captureLagMs}ms · output latency ${diagnostics.outputLatencyMs}ms ·
|
||||
clock offset ${diagnostics.serverOffsetMs}ms · rounds sat out ${diagnostics.skippedRounds} ·
|
||||
input peak ${diagnostics.inputPeak}${diagnostics.clipping ? " <strong>CLIPPING</strong>" : ""}
|
||||
</p>`;
|
||||
|
||||
return diagnostics;
|
||||
},
|
||||
|
||||
recordHistory(metres) {
|
||||
this.history.push(metres);
|
||||
if (this.history.length > this.HISTORY_LENGTH) this.history.shift();
|
||||
|
||||
const spread = Math.max(...this.history) - Math.min(...this.history);
|
||||
return { spread, count: this.history.length };
|
||||
},
|
||||
|
||||
setStatus(message) {
|
||||
this.elements.status.textContent = message;
|
||||
return message;
|
||||
},
|
||||
|
||||
renderWarnings(audio) {
|
||||
this.elements.warnings.innerHTML = "";
|
||||
|
||||
for (const warning of audio.warnings) {
|
||||
const item = document.createElement("p");
|
||||
item.className = "echo-warning";
|
||||
item.textContent = warning;
|
||||
this.elements.warnings.appendChild(item);
|
||||
}
|
||||
|
||||
return audio.warnings.length;
|
||||
},
|
||||
|
||||
renderRoster(devices) {
|
||||
this.elements.roster.innerHTML = "";
|
||||
|
||||
for (const device of devices) {
|
||||
const row = document.createElement("li");
|
||||
row.className = device.deviceId === EchoSession.deviceId ? "echo-device echo-device-self" : "echo-device";
|
||||
row.textContent = `${device.displayName} · ${device.sampleRate}Hz`;
|
||||
this.elements.roster.appendChild(row);
|
||||
}
|
||||
|
||||
if (devices.length < 2) this.setStatus("Waiting for a second device to join this room.");
|
||||
return devices.length;
|
||||
},
|
||||
|
||||
renderSolved({ result, reports, solved }) {
|
||||
this.roundsSeen++;
|
||||
const raw = EchoDsp.buildDistanceMatrix(
|
||||
reports.map(report => ({ ...report, epsilon: 0 })),
|
||||
{ speedOfSound: EchoDsp.speedOfSound(EchoSession.TEMPERATURE_CELSIUS) }
|
||||
);
|
||||
|
||||
this.lastSolved = { result, reports, solved, raw };
|
||||
this.renderReadout(reports, solved, raw);
|
||||
this.renderPairs(reports, solved, raw);
|
||||
this.setStatus(`Round ${this.roundsSeen} · ${reports.length} of ${EchoSession.devices.length} devices reported`);
|
||||
return solved;
|
||||
},
|
||||
|
||||
renderReadout(reports, solved, raw) {
|
||||
const readout = this.elements.readout;
|
||||
|
||||
if (reports.length !== 2 || solved.matrix[0][1] === null) {
|
||||
readout.innerHTML = `<span class="echo-readout-idle">${reports.length < 2 ? "waiting for a pair" : "measuring…"}</span>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const corrected = solved.matrix[0][1];
|
||||
const { spread, count } = this.recordHistory(corrected);
|
||||
readout.innerHTML = `
|
||||
<span class="echo-metres">${corrected.toFixed(2)}<small>m</small></span>
|
||||
<span class="echo-readout-detail">
|
||||
raw ${raw[0][1].toFixed(3)}m · calibration +${(corrected - raw[0][1]).toFixed(3)}m ·
|
||||
spread over last ${count} ${spread.toFixed(2)}m
|
||||
</span>`;
|
||||
|
||||
return corrected;
|
||||
},
|
||||
|
||||
renderPairs(reports, solved, raw) {
|
||||
const rows = [];
|
||||
|
||||
for (let i = 0; i < reports.length; i++)
|
||||
for (let j = i + 1; j < reports.length; j++) {
|
||||
const dropped = !solved.keep.includes(i) || !solved.keep.includes(j);
|
||||
const measured = solved.matrix[i][j];
|
||||
rows.push(`<tr class="${dropped ? "echo-dropped" : ""}">
|
||||
<td>${reports[i].deviceId} ↔ ${reports[j].deviceId}</td>
|
||||
<td>${measured === null ? "—" : measured.toFixed(3) + " m"}</td>
|
||||
<td>${raw[i][j] === null ? "—" : raw[i][j].toFixed(3) + " m"}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
this.elements.pairs.innerHTML = rows.length
|
||||
? `<table><thead><tr><th>pair</th><th>range</th><th>raw</th></tr></thead><tbody>${rows.join("")}</tbody></table>`
|
||||
: "";
|
||||
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
/** The matched-filter trace, with a marker on each arrival the detector accepted. */
|
||||
renderTrace({ envelope, detected, round }) {
|
||||
const canvas = this.elements.trace;
|
||||
const context = canvas.getContext("2d");
|
||||
const width = (canvas.width = canvas.clientWidth);
|
||||
const height = (canvas.height = 160);
|
||||
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length).value || 1;
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.strokeStyle = "rgba(9, 255, 0, 0.75)";
|
||||
context.beginPath();
|
||||
|
||||
const bucket = envelope.length / width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const start = Math.floor(x * bucket);
|
||||
const highest = EchoDsp.maxInRange(envelope, start, Math.min(envelope.length, Math.floor(start + bucket))).value;
|
||||
const y = height - (highest / peak) * (height - 8) - 4;
|
||||
x === 0 ? context.moveTo(x, y) : context.lineTo(x, y);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
this.drawMarkers(context, detected, round, envelope.length, width, height);
|
||||
return canvas;
|
||||
},
|
||||
|
||||
drawMarkers(context, detected, round, envelopeLength, width, height) {
|
||||
if (!detected) return null;
|
||||
|
||||
context.font = "11px 'Cascadia Code', monospace";
|
||||
|
||||
detected.peaks.forEach((peak, slot) => {
|
||||
if (!peak) return;
|
||||
|
||||
const x = (peak.index / envelopeLength) * width;
|
||||
const own = slot === round.ownSlot;
|
||||
context.strokeStyle = own ? "#5fff5f" : "rgba(255, 255, 255, 0.55)";
|
||||
context.fillStyle = context.strokeStyle;
|
||||
context.beginPath();
|
||||
context.moveTo(x, 0);
|
||||
context.lineTo(x, height);
|
||||
context.stroke();
|
||||
context.fillText(own ? `self (${slot})` : `slot ${slot}`, x + 4, 14 + slot * 13);
|
||||
});
|
||||
|
||||
return detected.peaks.length;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => EchoPage.start());
|
||||
@@ -0,0 +1,641 @@
|
||||
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
|
||||
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
|
||||
// so it's reachable without looking, and re-renders after every action because the
|
||||
// next player's numbers are different.
|
||||
const ActionBar = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
raiseOpen: false,
|
||||
raiseDraft: null,
|
||||
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
},
|
||||
|
||||
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
||||
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
||||
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
||||
// constant either: opening the raise pad grows it. Publishing the measured height
|
||||
// as a custom property lets the layout and the toast both stay clear of it without
|
||||
// anyone guessing a number.
|
||||
watchHeight() {
|
||||
const host = document.getElementById("actionBar");
|
||||
if (!host || this.heightWatcher) return;
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.heightWatcher = new ResizeObserver(publish);
|
||||
this.heightWatcher.observe(host);
|
||||
}
|
||||
window.addEventListener("resize", publish);
|
||||
window.addEventListener("orientationchange", publish);
|
||||
publish();
|
||||
},
|
||||
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
// the layout this is about to change. Measuring it would mean shrinking the felt,
|
||||
// moving the seats, and getting a different answer on the next pass.
|
||||
SEAT_OVERHANG_PX: 37,
|
||||
FELT_GAP_PX: 8,
|
||||
FELT_MIN_PX: 220,
|
||||
|
||||
// How tall the felt can be and still leave the whole ring of seats above the bar.
|
||||
//
|
||||
// Publishing the bar's height was only half the job. The wide layout never used it:
|
||||
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
|
||||
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
|
||||
// 117px to 172px the moment there's a side pot to show, which is exactly when the
|
||||
// dealer most needs to see who is still in. Measured with two side pots, the bottom
|
||||
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
|
||||
//
|
||||
// The wrap's offset is taken in document space rather than viewport space, so
|
||||
// scrolling the page doesn't resize the table under the dealer's hands.
|
||||
publishFeltRoom(barHeight) {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap) return;
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
const room = window.innerHeight - barHeight - topInDocument -
|
||||
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
// still holds during setup, because a seated player is a live seat whether or not
|
||||
// the betting has been opened. An empty table stays uncluttered.
|
||||
isRunning() {
|
||||
return Betting.liveSeats(this.state).length > 0;
|
||||
},
|
||||
|
||||
render() {
|
||||
const host = document.getElementById("actionBar");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
if (!this.isRunning()) {
|
||||
host.hidden = true;
|
||||
document.body.classList.remove("hasActionBar");
|
||||
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
||||
this.stopTankTimer();
|
||||
this.tankSeatIndex = null;
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
document.body.classList.add("hasActionBar");
|
||||
|
||||
host.appendChild(this.renderStatus());
|
||||
|
||||
const breakdown = this.renderPotBreakdown();
|
||||
if (breakdown) host.appendChild(breakdown);
|
||||
|
||||
if (!this.state.bettingOpen) {
|
||||
host.appendChild(this.renderDealPrompt());
|
||||
} else if (Betting.isHandOver(this.state)) {
|
||||
host.appendChild(this.renderHandOver());
|
||||
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
||||
host.appendChild(this.renderStreetDone());
|
||||
} else if (this.raiseOpen) {
|
||||
host.appendChild(this.renderRaisePad());
|
||||
} else {
|
||||
host.appendChild(this.renderActions());
|
||||
}
|
||||
|
||||
this.syncTankTimer();
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionStatus";
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "actionStreet";
|
||||
street.textContent = Betting.streetLabel(this.state.street);
|
||||
row.appendChild(street);
|
||||
|
||||
const pot = document.createElement("span");
|
||||
pot.className = "actionPot";
|
||||
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
||||
row.appendChild(pot);
|
||||
|
||||
// How many are still in the hand — part of the standard per-street
|
||||
// announcement, and the number a dealer is asked for most often. Before the
|
||||
// deal it counts the table as it will be dealt, so a busted player waiting on a
|
||||
// rebuy isn't announced as one of the players. Read-only: the sitting-out
|
||||
// itself happens when the hand is dealt, not while the bar is being drawn.
|
||||
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
|
||||
const remaining = Betting.liveSeats(counted).length;
|
||||
const players = document.createElement("span");
|
||||
players.className = "actionPlayers";
|
||||
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
||||
row.appendChild(players);
|
||||
|
||||
// Nobody is being asked to act because nobody can — say so, or the bar just
|
||||
// looks like it's waiting for something.
|
||||
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
||||
const runout = document.createElement("span");
|
||||
runout.className = "actionRunout";
|
||||
const stillIn = Betting.liveSeats(this.state).length;
|
||||
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
const index = this.state.actingSeatIndex;
|
||||
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
||||
const player = this.state.seats[index];
|
||||
const who = document.createElement("span");
|
||||
who.className = "actionWho";
|
||||
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||
row.appendChild(who);
|
||||
|
||||
const tank = document.createElement("span");
|
||||
tank.className = "actionTank";
|
||||
tank.id = "actionTank";
|
||||
tank.title = "How long this player has been thinking";
|
||||
tank.textContent = "0:00";
|
||||
row.appendChild(tank);
|
||||
}
|
||||
|
||||
// A standing control rather than a toast per action: a dealer taps this bar
|
||||
// constantly, and a toast after every fold would be noise they'd learn to
|
||||
// ignore — which is exactly when they'd miss the one that mattered.
|
||||
const undoLabel = this.hooks.lastActionLabel();
|
||||
if (undoLabel) {
|
||||
const undo = document.createElement("button");
|
||||
undo.type = "button";
|
||||
undo.className = "actionUndo";
|
||||
undo.title = `Undo ${undoLabel}`;
|
||||
undo.textContent = `↶ ${undoLabel}`;
|
||||
// U undoes, and nothing said so — this control was the only place a dealer
|
||||
// could have learned it, and it was the one action bar control without its
|
||||
// key printed on it.
|
||||
const key = document.createElement("kbd");
|
||||
// Its own class, not .actionKey: that one pins itself to the top-right
|
||||
// corner of a big action button, and this control is a 30px pill with
|
||||
// overflow hidden and no positioning context — the cap escaped it and
|
||||
// floated above the bar. Here the key belongs inline, after the label.
|
||||
key.className = "actionUndoKey";
|
||||
key.textContent = this.KEY_HINTS.actionUndo;
|
||||
undo.appendChild(key);
|
||||
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||
row.appendChild(undo);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
// ---- Think timer ----
|
||||
//
|
||||
// A player who hasn't noticed the action is on them costs more time than anything
|
||||
// else a dealer does, so the bar shows how long the current player has been in the
|
||||
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
|
||||
// tells the dealer when it's worth a nudge.
|
||||
NUDGE_AFTER_S: 30,
|
||||
SLOW_AFTER_S: 60,
|
||||
|
||||
tankSeatIndex: null,
|
||||
tankStartedAt: null,
|
||||
tankInterval: null,
|
||||
|
||||
// Restarts only when the action actually moves — a re-render for any other reason
|
||||
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||
syncTankTimer() {
|
||||
const index = this.state.actingSeatIndex;
|
||||
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
|
||||
|
||||
if (!running) {
|
||||
this.stopTankTimer();
|
||||
this.tankSeatIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tankSeatIndex !== index) {
|
||||
this.tankSeatIndex = index;
|
||||
this.tankStartedAt = Date.now();
|
||||
}
|
||||
|
||||
if (!this.tankInterval) {
|
||||
this.tankInterval = setInterval(() => this.paintTank(), 1000);
|
||||
}
|
||||
this.paintTank();
|
||||
},
|
||||
|
||||
stopTankTimer() {
|
||||
if (this.tankInterval) {
|
||||
clearInterval(this.tankInterval);
|
||||
this.tankInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
|
||||
// buttons under the dealer's thumb.
|
||||
paintTank() {
|
||||
const el = document.getElementById("actionTank");
|
||||
if (!el || this.tankStartedAt === null) return;
|
||||
|
||||
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||
},
|
||||
|
||||
// Shown only once someone is all-in and the money has actually split — a single
|
||||
// undivided pot needs no announcement, and a line that's always there stops being
|
||||
// read.
|
||||
renderPotBreakdown() {
|
||||
// Only worth showing once someone is actually all-in. Mid-street the amounts in
|
||||
// front of players are unequal all the time — the blinds alone are unequal —
|
||||
// and layering that would announce a side pot on every hand before anyone had
|
||||
// even acted.
|
||||
const someoneAllIn = this.state.seats.some(
|
||||
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
|
||||
);
|
||||
if (!someoneAllIn) return null;
|
||||
|
||||
const contributions = this.state.seats.map((player, index) => ({
|
||||
id: player.id,
|
||||
index,
|
||||
contribution: Betting.handTotal(player),
|
||||
folded: player.folded,
|
||||
}));
|
||||
|
||||
const nameById = new Map(
|
||||
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
||||
);
|
||||
|
||||
return Render.buildPotBreakdown(
|
||||
PotCalculator.layers(contributions),
|
||||
PotCalculator.uncalledReturns(contributions),
|
||||
id => nameById.get(id)
|
||||
);
|
||||
},
|
||||
|
||||
// Which key presses each button. Shown on the button itself and hidden on touch
|
||||
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
||||
// on a phone is just clutter.
|
||||
KEY_HINTS: {
|
||||
actionFold: "F",
|
||||
actionCheck: "C",
|
||||
actionCall: "C",
|
||||
actionRaise: "R",
|
||||
actionAdvance: "↵",
|
||||
raiseConfirm: "↵",
|
||||
raiseCancel: "esc",
|
||||
raiseAllIn: "A",
|
||||
actionUndo: "U",
|
||||
},
|
||||
|
||||
button(label, className, onClick, sublabel) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `actionBtn ${className}`;
|
||||
|
||||
const main = document.createElement("span");
|
||||
main.className = "actionBtnLabel";
|
||||
main.textContent = label;
|
||||
btn.appendChild(main);
|
||||
|
||||
// Looked up by any of the button's classes, so a button can carry a shared
|
||||
// class for its looks and a specific one for its key — the raise pad's four
|
||||
// quick amounts are all raiseQuickBtn, but only All-in has a key.
|
||||
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
|
||||
if (hint) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "actionKey";
|
||||
kbd.textContent = hint;
|
||||
btn.appendChild(kbd);
|
||||
}
|
||||
|
||||
if (sublabel) {
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "actionBtnSub";
|
||||
sub.textContent = sublabel;
|
||||
btn.appendChild(sub);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
// Every action taken from the bar goes through here, which is also where it gets
|
||||
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
||||
// tool a dealer can't talk their way out of.
|
||||
commit(label, mutate) {
|
||||
this.hooks.beginAction(label);
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
this.hooks.rerender();
|
||||
},
|
||||
|
||||
renderActions() {
|
||||
const index = this.state.actingSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const who = HoldemState.displayName(player, index);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
|
||||
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
|
||||
|
||||
if (owed <= 0) {
|
||||
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
|
||||
} else if (owed >= stack) {
|
||||
// Calling would put them all-in, so say that rather than quoting a number
|
||||
// they can't cover.
|
||||
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||
} else {
|
||||
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
|
||||
}
|
||||
|
||||
if (stack > owed) {
|
||||
// Not "does this player owe anything" — the big blind owes nothing and is
|
||||
// still raising. It's whether there is a live bet to raise.
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
|
||||
this.raiseOpen = true;
|
||||
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||
this.render();
|
||||
}));
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
||||
// button, and only reaches for digits when the table does something odd.
|
||||
renderRaisePad() {
|
||||
// A toast is transient news about the action just taken; the raise pad is the
|
||||
// dealer deciding the next one. On a short screen they compete for the same
|
||||
// strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
|
||||
// which floats just above the bar — gets pushed up over the table, covering
|
||||
// seats outright. Rather than squeeze both in, the older one gets out of the
|
||||
// way. Nothing is lost: the toast's Undo is also the bar's own undo control.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
const index = this.state.actingSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const maxTo = Betting.round(Betting.committed(player) + stack);
|
||||
const pot = Betting.potSoFar(this.state);
|
||||
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
||||
// what this player can actually put in, which is what the Min button should
|
||||
// offer. `legalMin` is what the rules require — for a short stack the two are
|
||||
// different, and it's the rules one the warnings have to be measured against,
|
||||
// or a player whose whole stack is under a full raise never gets told that.
|
||||
const legalMin = Betting.minRaiseTo(this.state);
|
||||
const minTo = Math.min(legalMin, maxTo);
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "raisePad";
|
||||
|
||||
const quick = document.createElement("div");
|
||||
quick.className = "raiseQuick";
|
||||
|
||||
const options = [
|
||||
{ label: "Min", value: minTo },
|
||||
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
||||
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
||||
{ label: "All-in", value: maxTo },
|
||||
];
|
||||
options.forEach(option => {
|
||||
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
|
||||
this.raiseDraft = value;
|
||||
this.render();
|
||||
}, Money.format(value));
|
||||
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
|
||||
quick.appendChild(btn);
|
||||
});
|
||||
wrap.appendChild(quick);
|
||||
|
||||
const entry = document.createElement("div");
|
||||
entry.className = "raiseEntry";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.className = "raiseInput";
|
||||
input.value = this.raiseDraft ?? minTo;
|
||||
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
|
||||
input.addEventListener("input", () => {
|
||||
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||
});
|
||||
entry.appendChild(input);
|
||||
|
||||
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
|
||||
this.raiseOpen = false;
|
||||
this.render();
|
||||
}));
|
||||
|
||||
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
|
||||
// under it to raise past.
|
||||
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
|
||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||
this.raiseOpen = false;
|
||||
const who = HoldemState.displayName(player, index);
|
||||
const label = raising
|
||||
? `${who}'s raise to ${Money.format(target)}`
|
||||
: `${who}'s bet of ${Money.format(target)}`;
|
||||
this.commit(label, () => Betting.raiseTo(this.state, index, target));
|
||||
}));
|
||||
|
||||
wrap.appendChild(entry);
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "raiseNote";
|
||||
note.hidden = true;
|
||||
wrap.appendChild(note);
|
||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// Says something when the amount typed isn't a legal raise. It never blocks —
|
||||
// a dealer overruling the arithmetic is right more often than the arithmetic is,
|
||||
// and the whole tool is built on tapping what the humans actually did.
|
||||
//
|
||||
// But an under-minimum raise from a player with chips behind them is the single
|
||||
// most common thing that stops a home game dead, and until now the bar took it
|
||||
// without a word: type 13 when the minimum is 18 and it was simply accepted. The
|
||||
// engine then treats it as a short raise and correctly doesn't reopen the betting,
|
||||
// which is right for an all-in and wrong for somebody who just misheard the bet.
|
||||
updateRaiseNote(wrap, minTo, maxTo) {
|
||||
const note = wrap.querySelector(".raiseNote");
|
||||
if (!note) return;
|
||||
|
||||
const typed = Number(this.raiseDraft);
|
||||
const current = this.state.currentBet || 0;
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
||||
|
||||
// Going all-in for less than a full raise is perfectly legal, and worth saying
|
||||
// out loud because of what follows from it.
|
||||
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
||||
|
||||
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
||||
// The way out of an under-minimum differs: with a bet in front of you the
|
||||
// alternative is calling it, and with an empty pot there's nothing to call.
|
||||
note.textContent = raising
|
||||
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
|
||||
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
|
||||
note.className = "raiseNote raiseNoteWarn";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
||||
note.textContent = raising
|
||||
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
|
||||
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
|
||||
note.className = "raiseNote raiseNoteInfo";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; }
|
||||
|
||||
if (typed > maxTo + Betting.EPSILON_CHIPS) {
|
||||
note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`;
|
||||
note.className = "raiseNote raiseNoteInfo";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed <= current) {
|
||||
note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`;
|
||||
note.className = "raiseNote raiseNoteWarn";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
note.hidden = true;
|
||||
},
|
||||
|
||||
// The table is set but the cards aren't out. This is the step that used to happen
|
||||
// by itself the instant the previous hand ended, which meant blinds were committed
|
||||
// while people were still sitting down — and the blind badges would drift away from
|
||||
// the money already posted as seats changed. Now the dealer says when.
|
||||
renderDealPrompt() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dealPrompt";
|
||||
|
||||
const preview = Betting.blindPreview(this.state);
|
||||
const seated = Betting.liveSeats(this.state).length;
|
||||
const broke = Betting.brokeSeats(this.state);
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
const listNames = indexes => indexes.length === 1
|
||||
? name(indexes[0])
|
||||
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine";
|
||||
|
||||
if (!preview) {
|
||||
// Two players is the least you can deal. Say what's missing rather than
|
||||
// offering a button that would quietly do nothing — and say the *right*
|
||||
// thing: "only one player is dealt in" is wrong when four are sitting there
|
||||
// and three of them are just out of chips.
|
||||
const withChips = seated - broke.length;
|
||||
if (broke.length > 0 && withChips < 2) {
|
||||
line.textContent = withChips === 1
|
||||
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
|
||||
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
||||
} else {
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
: "Only one player is dealt in — a hand needs two.";
|
||||
}
|
||||
line.classList.add("dealPromptBlocked");
|
||||
wrap.appendChild(line);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
line.textContent =
|
||||
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
wrap.appendChild(line);
|
||||
|
||||
// Dealing relocates a button sitting on an empty chair. That has to be said
|
||||
// before it happens rather than discovered afterwards — a fresh table starts
|
||||
// the button on seat 1 whether or not anyone is sitting there.
|
||||
if (preview.buttonMoves) {
|
||||
const moved = document.createElement("p");
|
||||
moved.className = "dealPromptLine dealPromptAside";
|
||||
moved.textContent =
|
||||
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
|
||||
wrap.appendChild(moved);
|
||||
}
|
||||
|
||||
// Dealing sits out anyone with nothing behind. Said before it happens, for the
|
||||
// same reason the button move is: a dealer shouldn't find out from the felt.
|
||||
if (preview.satOut.length > 0) {
|
||||
const out = document.createElement("p");
|
||||
out.className = "dealPromptLine dealPromptAside";
|
||||
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
|
||||
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
|
||||
wrap.appendChild(out);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
|
||||
const isRiver = this.state.street === "river";
|
||||
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
||||
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
return;
|
||||
}
|
||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||
}));
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
renderHandOver() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
const live = Betting.liveSeats(this.state);
|
||||
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
||||
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
));
|
||||
return row;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = ActionBar;
|
||||
}
|
||||
@@ -0,0 +1,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,483 @@
|
||||
// Live betting: who's to act, what it costs them, and where the money goes.
|
||||
//
|
||||
// This is bookkeeping, not a game — the dealer taps what the humans at the table
|
||||
// actually did, and the tool keeps the arithmetic straight. It deliberately never
|
||||
// blocks an action it thinks is "illegal"; a dealer overruling the maths is right
|
||||
// more often than the maths is.
|
||||
//
|
||||
// Money model: `player.committed` is what a seat has put in on the current street.
|
||||
// When a street closes it sweeps into `player.amountOverride`, which is the seat's
|
||||
// total for the hand — the same field the pot badge, the pot wizard, the standings
|
||||
// and the CSV already read. So the betting bar is a fast way to fill in a field
|
||||
// that already existed, and nothing downstream had to change.
|
||||
const Betting = {
|
||||
STREETS: ["preflop", "flop", "turn", "river"],
|
||||
|
||||
streetLabel(street) {
|
||||
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||
},
|
||||
|
||||
round(amount) {
|
||||
return Money.round(amount);
|
||||
},
|
||||
|
||||
// ---- Reading the table ----
|
||||
|
||||
liveSeats(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(s => s.player.inHand && !s.player.folded);
|
||||
},
|
||||
|
||||
// Who would post if the hand were dealt right now. Used to preview the blinds
|
||||
// while the table is still being set, so a dealer can see where they'll land
|
||||
// before any money moves.
|
||||
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
|
||||
//
|
||||
// Not the same as all-in: an all-in player has money riding on the hand. This is
|
||||
// somebody who busted and hasn't bought back in yet, which a home game produces
|
||||
// about once an hour. A seat with no stack *recorded* isn't broke either —
|
||||
// stackOf falls back to the table's starting stack for those — only an explicit
|
||||
// zero counts.
|
||||
brokeSeats(state) {
|
||||
const broke = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
if (!player.inHand) return;
|
||||
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
|
||||
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
|
||||
broke.push(index);
|
||||
});
|
||||
return broke;
|
||||
},
|
||||
|
||||
// The table as it will be once the seats that can't post are dealt around. A view,
|
||||
// not a change: the preview has to ask the same question the deal will answer, and
|
||||
// asking it of a different shape of table is how the two came apart last time.
|
||||
tableAsDealt(state) {
|
||||
const broke = this.brokeSeats(state);
|
||||
if (broke.length === 0) return state;
|
||||
const skip = new Set(broke);
|
||||
return {
|
||||
...state,
|
||||
seats: state.seats.map((player, index) =>
|
||||
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
|
||||
};
|
||||
},
|
||||
|
||||
blindPreview(state) {
|
||||
// Dealing relocates a button parked on an empty chair before it works out who
|
||||
// posts, so the preview has to answer for the button where it *will* be. Asking
|
||||
// from where it currently sits gave a different pair of blinds than the deal
|
||||
// produced — the preview promising one thing and doing another is exactly the
|
||||
// guessing this whole step exists to remove. Same reason the broke seats come
|
||||
// out first: the preview once promised a small blind from a seat with no chips.
|
||||
const dealt = this.tableAsDealt(state);
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||
? dealt
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
|
||||
const satOut = this.brokeSeats(state);
|
||||
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
||||
if (sbIndex === null || bbIndex === null) return null;
|
||||
return {
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual,
|
||||
buttonIndex,
|
||||
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||
satOut,
|
||||
smallBlind: this.round(state.smallBlind || 0),
|
||||
bigBlind: this.round(state.bigBlind || 0),
|
||||
};
|
||||
},
|
||||
|
||||
// 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);
|
||||
// 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;
|
||||
|
||||
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||
|
||||
// It takes two players with chips to have a bet. Once everyone left is all-in
|
||||
// the rest of the board is just dealt out.
|
||||
if (!this.canStillBet(state)) {
|
||||
state.actingSeatIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// After the flop the first live player left of the button acts — which heads-up
|
||||
// is the big blind, since the button is the small blind. Naming the big blind
|
||||
// directly also covers a button parked on a seat nobody is sitting in, and it
|
||||
// still has to be someone with chips left to bet.
|
||||
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
const bigBlindCanAct =
|
||||
headsUp &&
|
||||
bbIndex !== null &&
|
||||
!state.seats[bbIndex].folded &&
|
||||
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
|
||||
|
||||
state.actingSeatIndex = bigBlindCanAct
|
||||
? bbIndex
|
||||
: this.nextToAct(state, state.dealerSeatIndex);
|
||||
},
|
||||
|
||||
isHandOver(state) {
|
||||
return this.liveSeats(state).length <= 1;
|
||||
},
|
||||
|
||||
// Players still in the hand who have chips left to put in. Fewer than two of them
|
||||
// means the betting is finished even though the hand isn't.
|
||||
seatsWithChips(state) {
|
||||
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
|
||||
},
|
||||
|
||||
EPSILON_CHIPS: Money.EPSILON,
|
||||
|
||||
canStillBet(state) {
|
||||
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
|
||||
},
|
||||
|
||||
// True when the hand is still live but nobody can bet again — the dealer is just
|
||||
// running the board out.
|
||||
isRunningOut(state) {
|
||||
// Only while a hand is actually being played. Before the deal, a table of
|
||||
// busted players who haven't rebought looks exactly like a table of all-ins to
|
||||
// this test, and the bar was announcing a run-out over a felt with no cards and
|
||||
// no chips on it.
|
||||
if (!state.bettingOpen) return false;
|
||||
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||
},
|
||||
|
||||
// True while nothing has happened in this hand except the blinds going in. Up to
|
||||
// that point the dealer can still move the button or rename the blinds and have
|
||||
// it cost nothing — which is the state a home game spends a lot of time in,
|
||||
// arguing about whose turn it is before the cards come out.
|
||||
onlyBlindsIn(state) {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// Pulls the blinds back off the table and posts them again from wherever the
|
||||
// button and the blind assignment now say they belong. Refunding first is what
|
||||
// keeps the chip count straight when the button gets passed round three or four
|
||||
// times before anybody is happy with it.
|
||||
repostBlinds(state) {
|
||||
state.seats.forEach(player => {
|
||||
const back = this.committed(player);
|
||||
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
||||
player.committed = 0;
|
||||
player.hasActed = false;
|
||||
});
|
||||
this.beginHand(state);
|
||||
},
|
||||
|
||||
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
||||
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||
//
|
||||
// Called when the dealer says the table is ready, not automatically when the
|
||||
// previous hand ends — everything before this point is setup, where seats and the
|
||||
// button can move freely because no money has been committed.
|
||||
beginHand(state) {
|
||||
state.bettingOpen = true;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
// 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);
|
||||
|
||||
if (sbIndex === null || bbIndex === null) {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sb > 0) this.putIn(state, sbIndex, sb);
|
||||
if (bb > 0) this.putIn(state, bbIndex, bb);
|
||||
|
||||
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
|
||||
// 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.
|
||||
state.actingSeatIndex = headsUp
|
||||
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
||||
: this.nextToAct(state, bbIndex);
|
||||
},
|
||||
|
||||
// Winners get their share put back in front of them.
|
||||
creditWinnings(state, payouts) {
|
||||
payouts.forEach(payout => {
|
||||
const player = state.seats.find(p => p.id === payout.id);
|
||||
if (!player) return;
|
||||
player.stack = this.round(this.stackOf(state, player) + payout.amount);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Betting;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Tap a card instead of typing it.
|
||||
//
|
||||
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
||||
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
||||
// card land in two players' hands. The picker is one tap per card, greys out cards
|
||||
// already dealt somewhere else at the table, and stores exactly the same two-character
|
||||
// string the rest of the tool has always read.
|
||||
const CardPicker = {
|
||||
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
||||
SUITS: [
|
||||
{ code: "S", symbol: "♠", red: false },
|
||||
{ code: "H", symbol: "♥", red: true },
|
||||
{ code: "D", symbol: "♦", red: true },
|
||||
{ code: "C", symbol: "♣", red: false },
|
||||
],
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = card;
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the second card — a dealer picking hole cards is always
|
||||
// picking two, and making them re-open the picker doubles the taps.
|
||||
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||
this.cardIndex = 1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = "";
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const used = this.cardsInUse();
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent =
|
||||
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
[0, 1].forEach(slot => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = player.cards[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
this.render();
|
||||
});
|
||||
slots.appendChild(btn);
|
||||
});
|
||||
|
||||
const grid = document.getElementById("cardPickerGrid");
|
||||
grid.innerHTML = "";
|
||||
this.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => {
|
||||
const card = `${rank}${suit.code}`;
|
||||
const takenBy = used.get(card);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = CardPicker;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Toasts and confirmation dialogs, so the table never has to fall back on the
|
||||
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
|
||||
// the rest of the page, and on a phone it's a full-screen interruption — all of
|
||||
// which is a bad trade for a dealer trying to keep a game moving.
|
||||
|
||||
// A short-lived message at the bottom of the screen, optionally with one action
|
||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||
const Toast = {
|
||||
DEFAULT_DURATION_MS: 8000,
|
||||
timer: null,
|
||||
|
||||
show(message, options = {}) {
|
||||
const host = document.getElementById("toastHost");
|
||||
if (!host) return;
|
||||
|
||||
this.dismiss();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.setAttribute("role", "status");
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "toastText";
|
||||
text.textContent = message;
|
||||
toast.appendChild(text);
|
||||
|
||||
if (options.actionLabel && options.onAction) {
|
||||
const action = document.createElement("button");
|
||||
action.type = "button";
|
||||
action.className = "toastAction";
|
||||
action.textContent = options.actionLabel;
|
||||
action.addEventListener("click", () => {
|
||||
this.dismiss();
|
||||
options.onAction();
|
||||
});
|
||||
toast.appendChild(action);
|
||||
}
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toastClose";
|
||||
close.setAttribute("aria-label", "Dismiss");
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", () => this.dismiss());
|
||||
toast.appendChild(close);
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
options.duration || this.DEFAULT_DURATION_MS
|
||||
);
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
|
||||
const Dialog = {
|
||||
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.getElementById("confirmOverlay");
|
||||
const modal = overlay.querySelector("#confirmModal");
|
||||
overlay.querySelector("#confirmTitle").textContent = title;
|
||||
overlay.querySelector("#confirmMessage").textContent = message;
|
||||
|
||||
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
|
||||
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
cancelBtn.textContent = cancelLabel;
|
||||
confirmBtn.classList.toggle("holdemBtnDanger", danger);
|
||||
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
|
||||
|
||||
const finish = result => {
|
||||
overlay.hidden = true;
|
||||
confirmBtn.removeEventListener("click", onAccept);
|
||||
cancelBtn.removeEventListener("click", onCancel);
|
||||
overlay.removeEventListener("click", onBackdrop);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
resolve(result);
|
||||
};
|
||||
const onAccept = () => finish(true);
|
||||
const onCancel = () => finish(false);
|
||||
const onBackdrop = e => {
|
||||
if (e.target === overlay) finish(false);
|
||||
};
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") finish(false);
|
||||
if (e.key === "Enter") finish(true);
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener("click", onAccept);
|
||||
cancelBtn.addEventListener("click", onCancel);
|
||||
overlay.addEventListener("click", onBackdrop);
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.hidden = false;
|
||||
modal.focus();
|
||||
confirmBtn.focus();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Toast, Dialog };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 = []) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
[...handHistory].reverse().forEach(entry => {
|
||||
const payouts = (entry.payouts || [])
|
||||
.map(p => `${p.label}: ${Money.format(p.amount)}`)
|
||||
.join("; ");
|
||||
rows.push([
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.forSheet(entry.pot),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
|
||||
// The settle-up numbers go in the same file as the hands they came from, so a
|
||||
// dealer handing over one export isn't asked for a second one at cash-out.
|
||||
if (standings.length > 0) {
|
||||
rows.push([]);
|
||||
rows.push(["Session Standings"]);
|
||||
// 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.
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
Money.forSheet(row.boughtIn || 0),
|
||||
Money.forSheet(row.chipsNow || 0),
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
]);
|
||||
});
|
||||
|
||||
// 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`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||
},
|
||||
|
||||
fileName() {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
||||
return `holdem-hand-history-${stamp}.csv`;
|
||||
},
|
||||
|
||||
download(handHistory, standings = []) {
|
||||
const csv = this.toCsv(handHistory, standings);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = this.fileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandHistoryExport;
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||
// by reference to every module at mount time, so it has to be refilled in place —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
// the old object and quietly ignoring the undo.
|
||||
const snapshotState = () => JSON.parse(JSON.stringify(state));
|
||||
|
||||
const restoreState = snapshot => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
};
|
||||
|
||||
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||
// way, so each one is undoable the same way the header buttons are.
|
||||
const seatAction = (index, message, mutate) => {
|
||||
const snapshot = snapshotState();
|
||||
mutate(state.seats[index], index);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
duration: 6000,
|
||||
});
|
||||
};
|
||||
|
||||
const nameOf = index => HoldemState.displayName(state.seats[index], index);
|
||||
|
||||
const hooks = {
|
||||
save: s => HoldemState.save(s),
|
||||
rerender: () => Render.renderAll(),
|
||||
|
||||
onSeatDoubleTap: index => {
|
||||
const player = state.seats[index];
|
||||
if (!player.inHand) return;
|
||||
if (player.folded) {
|
||||
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
|
||||
return;
|
||||
}
|
||||
seatAction(index, `${nameOf(index)} folds.`, () => {
|
||||
if (state.actingSeatIndex === index) Betting.fold(state, index);
|
||||
else player.folded = true;
|
||||
});
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
fold: index => hooks.onSeatDoubleTap(index),
|
||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||
toggleDealtIn: index => {
|
||||
const player = state.seats[index];
|
||||
const dealtIn = player.inHand;
|
||||
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||
const alreadyIn = Betting.handTotal(player);
|
||||
|
||||
// Somebody walking away mid-hand doesn't get their chips back — they're
|
||||
// dead money the winner collects. Say so, because the pot total pointedly
|
||||
// does not go down and a dealer needs to know that's deliberate.
|
||||
const message = dealtIn
|
||||
? (alreadyIn > 0
|
||||
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
||||
: `${nameOf(index)} sits out.`)
|
||||
: (handRunning
|
||||
? `${nameOf(index)} is in from the next hand.`
|
||||
: `${nameOf(index)} is dealt in.`);
|
||||
|
||||
seatAction(index, message, p => {
|
||||
p.inHand = !dealtIn;
|
||||
if (!p.inHand) {
|
||||
// Their chips stay put; only the fold flag is cleared, since being
|
||||
// out of the hand already covers not winning it.
|
||||
p.folded = false;
|
||||
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
||||
} else if (handRunning) {
|
||||
// Joining a hand already in progress means sitting this one out —
|
||||
// you can't buy into a pot you haven't been paying for. `folded`
|
||||
// is exactly that state, and the next hand clears it.
|
||||
p.folded = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Two players change chairs. Undoable like every other seat action, because a
|
||||
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||
swapSeats: (from, to) => {
|
||||
const movedName = nameOf(from);
|
||||
const intoName = nameOf(to);
|
||||
const wasEmpty = Render.isEmptySeat(state.seats[to]);
|
||||
runUndoable(
|
||||
wasEmpty
|
||||
? `${movedName} moved to seat ${to + 1}.`
|
||||
: `${movedName} and ${intoName} changed seats.`,
|
||||
() => HoldemState.swapSeats(state, from, to));
|
||||
},
|
||||
|
||||
makeDealer: index => {
|
||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
// the dealer gets told rather than finding out later.
|
||||
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||
|
||||
seatAction(index, willRepost
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
: `Button on ${nameOf(index)}.`, () => {
|
||||
state.dealerSeatIndex = index;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
assignBlind: (index, which) => {
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
||||
// Naming one blind pins the other where it already is, so the dealer only
|
||||
// has to state the part they're changing. If that would land both on one
|
||||
// player, the other blind moves to the next seat along.
|
||||
let partner = other !== null && other !== undefined && other !== index
|
||||
? other
|
||||
: Betting.nextLiveIndex(state, index);
|
||||
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||
|
||||
const label = which === "sb" ? "small blind" : "big blind";
|
||||
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
|
||||
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
|
||||
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||
|
||||
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
|
||||
(dealBackIn
|
||||
? handRunning ? " — in from the next hand." : " — dealt in."
|
||||
: ".");
|
||||
|
||||
seatAction(seatIndex, message, p => {
|
||||
// Chips in front and cash in are different questions — the stack moves
|
||||
// every hand, what they've handed over only moves when they buy.
|
||||
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
||||
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
|
||||
|
||||
if (dealBackIn) {
|
||||
p.inHand = true;
|
||||
// Same rule "Deal in" follows: you can't buy into a pot you haven't
|
||||
// been paying for, and `folded` is exactly that state for one hand.
|
||||
p.folded = handRunning;
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
const openPotWizard = () => {
|
||||
// Chips still in front of players belong in the pot being priced, so bank them
|
||||
// before the wizard reads anyone's contribution.
|
||||
Betting.sweepToHandTotals(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
PotWizard.open(state, payouts => {
|
||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||
if (payouts.length > 0) {
|
||||
HoldemState.recordHand(state, payouts);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
// hand's stacks are already right.
|
||||
Betting.creditWinnings(state, payouts);
|
||||
}
|
||||
// Settling a pot ends the hand; it doesn't start the next one. The
|
||||
// table goes back to setup so people can sit down or cash out before
|
||||
// any blinds are committed.
|
||||
HoldemState.startNextHand(state);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A short history of actions taken from the bar, so a misclicked Fold can be taken
|
||||
// back — and the one before it, since a dealer usually notices a beat late. Kept in
|
||||
// memory only: this is for the mistake you just made, not last night's hand.
|
||||
const MAX_UNDO_STEPS = 12;
|
||||
const actionHistory = [];
|
||||
|
||||
ActionBar.mount(state, {
|
||||
save: hooks.save,
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
|
||||
beginAction: label => {
|
||||
actionHistory.push({ label, snapshot: snapshotState() });
|
||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||
},
|
||||
|
||||
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
||||
|
||||
undoLast: () => {
|
||||
const step = actionHistory.pop();
|
||||
if (!step) return;
|
||||
restoreState(step.snapshot);
|
||||
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
||||
},
|
||||
});
|
||||
|
||||
// Anything that ends the hand makes the per-action history meaningless — the
|
||||
// snapshots refer to a hand that no longer exists.
|
||||
const clearActionHistory = () => { actionHistory.length = 0; };
|
||||
|
||||
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
|
||||
// calling the betting engine themselves — there's no second code path to drift out
|
||||
// of step with what the bar is actually offering right now.
|
||||
const pressInBar = selector => {
|
||||
const btn = document.querySelector(`#actionBar ${selector}`);
|
||||
if (!btn || btn.disabled) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
const quickRaiseButton = label =>
|
||||
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||
|
||||
Shortcuts.mount({
|
||||
fold: () => pressInBar(".actionFold"),
|
||||
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
|
||||
raise: () => pressInBar(".actionRaise"),
|
||||
allIn: () => {
|
||||
// Open the raise pad first if it isn't already, then take its All-in amount.
|
||||
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
|
||||
const quick = quickRaiseButton("All-in");
|
||||
if (!quick) return false;
|
||||
quick.click();
|
||||
return true;
|
||||
},
|
||||
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
||||
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||
// streets and showdown without reaching for the trackpad.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
||||
undo: () => pressInBar(".actionUndo"),
|
||||
cancel: () => pressInBar(".raiseCancel"),
|
||||
});
|
||||
|
||||
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||
// just happens, and the toast offers the way back for the few seconds anyone
|
||||
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||
// undo is for the mistake you just made, not one from last night's session.
|
||||
const runUndoable = (message, mutate) => {
|
||||
const snapshot = snapshotState();
|
||||
mutate();
|
||||
// Starting or settling a hand invalidates the per-action history: those
|
||||
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
|
||||
clearActionHistory();
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// A seat that has anything of a player in it — a name, chips, cash in, or a hand
|
||||
// in progress. Shrinking the table throws these away permanently, and it also
|
||||
// orphans them in the session standings, where they keep their winnings but lose
|
||||
// the stack and buy-in the settle-up is worked out from.
|
||||
const seatHasSomeoneIn = player =>
|
||||
!!(player.name && player.name.trim()) ||
|
||||
player.inHand ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0;
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||
const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
|
||||
|
||||
if (losing.length > 0) {
|
||||
const who = losing
|
||||
.map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
|
||||
.join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
|
||||
message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
|
||||
confirmLabel: "Remove them",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) {
|
||||
e.target.value = state.seatCount;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
|
||||
});
|
||||
|
||||
document.getElementById("directionSelect").addEventListener("change", e => {
|
||||
state.direction = e.target.value;
|
||||
hooks.save(state);
|
||||
Render.renderSeats();
|
||||
});
|
||||
|
||||
document.getElementById("basePotInput").addEventListener("input", e => {
|
||||
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("dedicatedDealerToggle").addEventListener("change", e => {
|
||||
state.dedicatedDealer = e.target.checked;
|
||||
hooks.save(state);
|
||||
Render.renderSeats();
|
||||
Render.renderTableHint();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
state.chipLegend.push({
|
||||
id: HoldemState.makeId("chip"),
|
||||
color: "#888888",
|
||||
value: 1,
|
||||
});
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||
// middle took the table from 800 chips to 792, and the toast said nothing
|
||||
// about it. Chips only ever leave this tool because a dealer meant them to.
|
||||
const unresolved = HoldemState.unresolvedContributions(state);
|
||||
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Throw away ${Money.format(unresolved)}?`,
|
||||
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
|
||||
confirmLabel: "Throw it away",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
});
|
||||
|
||||
// The load happens on the first line of this file, long before there's a Toast to
|
||||
// show it in, so this is checked here rather than wired as a callback. The standing
|
||||
// warning in the Session panel doesn't need it — that reads the flag every render —
|
||||
// but an empty table is a thing a dealer needs told, not left to notice.
|
||||
if (HoldemState.loadFailed) {
|
||||
Toast.show(
|
||||
"The saved table couldn't be read, so this one started empty.",
|
||||
{ duration: 12000 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||
//
|
||||
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||
// confirm — opened and closed by five different modules, and each one was missing
|
||||
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||
// 1 of 5 closed on Escape.
|
||||
//
|
||||
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||
// failure this is fixing in the first place.
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
open: [],
|
||||
|
||||
mount() {
|
||||
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||
// table-level shortcut sees them.
|
||||
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||
},
|
||||
|
||||
watch(overlay) {
|
||||
const sync = () => {
|
||||
const isOpen = !overlay.hidden;
|
||||
const known = this.open.some(entry => entry.overlay === overlay);
|
||||
if (isOpen && !known) this.onOpened(overlay);
|
||||
else if (!isOpen && known) this.onClosed(overlay);
|
||||
};
|
||||
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||
sync();
|
||||
},
|
||||
|
||||
sheetOf(overlay) {
|
||||
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||
},
|
||||
|
||||
focusableIn(overlay) {
|
||||
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||
if (el.closest("[hidden]")) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
},
|
||||
|
||||
onOpened(overlay) {
|
||||
const sheet = this.sheetOf(overlay);
|
||||
// aria-modal is only true while the thing is actually open, so it's set here
|
||||
// rather than baked into the markup.
|
||||
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||
|
||||
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||
|
||||
// Focus the first real control. A dialog that leaves focus on the button
|
||||
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||
// Content-only sheets get focus on the sheet so the title is announced.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||
sheet.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
onClosed(overlay) {
|
||||
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||
if (index === -1) return;
|
||||
const [entry] = this.open.splice(index, 1);
|
||||
|
||||
const sheet = this.sheetOf(overlay);
|
||||
if (sheet) sheet.removeAttribute("aria-modal");
|
||||
|
||||
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||
// drops focus to the top of the document and the next Tab starts from the
|
||||
// page header.
|
||||
const back = entry.returnFocus;
|
||||
if (back && back.isConnected && typeof back.focus === "function") {
|
||||
back.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
|
||||
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||
// overlay directly.
|
||||
dismiss(overlay) {
|
||||
const closer = overlay.querySelector("[data-modal-close]");
|
||||
if (closer) closer.click();
|
||||
else overlay.hidden = true;
|
||||
},
|
||||
|
||||
onKeyDown(event) {
|
||||
const top = this.topmost();
|
||||
if (!top) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dismiss(top.overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||
const focusable = this.focusableIn(top.overlay);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Modal;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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.
|
||||
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,315 @@
|
||||
// Walks the dealer through awarding the pot: "who won?", and if that player was
|
||||
// all-in for less than everyone else, "who's next?" — repeating until every dollar
|
||||
// in the pot has been assigned. Built on PotCalculator's pure round-resolution math.
|
||||
const PotWizard = {
|
||||
state: null,
|
||||
onFinished: null,
|
||||
entries: [],
|
||||
playerLabelById: new Map(),
|
||||
log: [],
|
||||
|
||||
open(state, onFinished) {
|
||||
this.state = state;
|
||||
this.onFinished = onFinished;
|
||||
|
||||
const players = state.seats.filter(
|
||||
p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0
|
||||
);
|
||||
this.playerLabelById = new Map(
|
||||
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
|
||||
);
|
||||
this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
|
||||
// The label carries name and cards together for the log and the payout lines;
|
||||
// the choice rows want them separately so each can be styled and read on its own.
|
||||
this.nameById = new Map(
|
||||
players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
|
||||
);
|
||||
this.cardsById = new Map(
|
||||
players.map(p => [p.id, (p.cards || []).filter(Boolean)])
|
||||
);
|
||||
const contributions = players.map(p => ({
|
||||
id: p.id,
|
||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
// A seat switched off mid-hand still has its chips in the pot, but it
|
||||
// can no more win them than a folded one can — same dead money, and the
|
||||
// wizard must not offer it as a winner.
|
||||
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||
}));
|
||||
this.entries = PotCalculator.buildEntries(contributions);
|
||||
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||
// it happens to be asking about.
|
||||
this.layers = PotCalculator.layers(contributions);
|
||||
this.returns = PotCalculator.uncalledReturns(contributions);
|
||||
this.log = [];
|
||||
this.totals = new Map();
|
||||
|
||||
document.getElementById("potWizardOverlay").hidden = false;
|
||||
this.renderStep();
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("potWizardOverlay").hidden = true;
|
||||
},
|
||||
|
||||
labelFor(player, seatIndex) {
|
||||
const name = HoldemState.displayName(player, seatIndex);
|
||||
const cards = (player.cards || []).filter(Boolean).map(c => Render.formatCard(c)).join(" ");
|
||||
return cards ? `${name} (${cards})` : name;
|
||||
},
|
||||
|
||||
addToTotal(id, amount) {
|
||||
this.totals.set(id, (this.totals.get(id) || 0) + amount);
|
||||
},
|
||||
|
||||
// A chopped pot rarely divides exactly, and the odd chip goes to the first player
|
||||
// left of the button — not to whoever the dealer happened to tick first. The
|
||||
// calculator hands leftovers out in the order it's given the winners, so ordering
|
||||
// them here is what makes the payout match the rule.
|
||||
orderFromButton(ids) {
|
||||
const distance = new Map();
|
||||
for (let step = 1; step <= this.state.seatCount; step++) {
|
||||
distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
|
||||
}
|
||||
return [...ids].sort((a, b) =>
|
||||
(distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
|
||||
);
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
const result = PotCalculator.resolveRound(this.entries, [contenders[0].id]);
|
||||
this.recordRound(result);
|
||||
this.entries = result.updatedEntries;
|
||||
contenders = PotCalculator.getContenders(this.entries);
|
||||
}
|
||||
|
||||
const body = document.getElementById("potWizardBody");
|
||||
body.innerHTML = "";
|
||||
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||
body.appendChild(this.renderLog());
|
||||
|
||||
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
|
||||
|
||||
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
|
||||
const warning = document.createElement("p");
|
||||
warning.className = "potWizardPrompt";
|
||||
|
||||
// Money left over because nobody could call it isn't a pot anyone won —
|
||||
// it goes back to whoever put it in. Telling the dealer to un-fold someone
|
||||
// to "win" it would be wrong.
|
||||
const returned = (this.returns || []).reduce((sum, r) => sum + r.amount, 0);
|
||||
if (Math.abs(returned - remaining) <= PotCalculator.EPSILON && returned > 0) {
|
||||
const who = this.returns.map(r => this.playerLabelById.get(r.id)).join(", ");
|
||||
warning.textContent = `${Money.format(remaining)} was never called — push it back to ${who} rather than paying it out.`;
|
||||
} else {
|
||||
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
|
||||
}
|
||||
body.appendChild(warning);
|
||||
body.appendChild(this.renderSummary());
|
||||
return;
|
||||
}
|
||||
|
||||
if (contenders.length === 0 || remaining <= PotCalculator.EPSILON) {
|
||||
body.appendChild(this.renderSummary());
|
||||
return;
|
||||
}
|
||||
|
||||
const round = document.createElement("div");
|
||||
round.className = "potWizardRound";
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
round.appendChild(prompt);
|
||||
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "potWizardChoices";
|
||||
contenders.forEach(c => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "potWizardChoice";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = c.id;
|
||||
// The row is the target, not the box — so the row has to look chosen too.
|
||||
checkbox.addEventListener("change", () => {
|
||||
label.classList.toggle("chosen", checkbox.checked);
|
||||
});
|
||||
label.appendChild(checkbox);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "potWizardChoiceText";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "potWizardChoiceName";
|
||||
name.textContent = this.nameById.get(c.id);
|
||||
text.appendChild(name);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
text.appendChild(upTo);
|
||||
label.appendChild(text);
|
||||
|
||||
// Cards sit apart from the name rather than inside it, so a dealer reading
|
||||
// a row across the table sees the hand, not a sentence.
|
||||
const cards = this.cardsById.get(c.id) || [];
|
||||
if (cards.length > 0) {
|
||||
const cardsEl = document.createElement("span");
|
||||
cardsEl.className = "potWizardChoiceCards";
|
||||
cards.forEach((card, i) => {
|
||||
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||||
const span = document.createElement("span");
|
||||
if (/[HD]$/i.test(card)) span.className = "cardRed";
|
||||
span.textContent = Render.formatCard(card);
|
||||
cardsEl.appendChild(span);
|
||||
});
|
||||
label.appendChild(cardsEl);
|
||||
}
|
||||
|
||||
choices.appendChild(label);
|
||||
});
|
||||
round.appendChild(choices);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "fieldHint";
|
||||
hint.textContent = "Check more than one to split the pot on a tie.";
|
||||
round.appendChild(hint);
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
||||
confirmBtn.textContent = "Confirm Winner(s)";
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
||||
if (selected.length === 0) return;
|
||||
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(selected));
|
||||
this.recordRound(result);
|
||||
this.entries = result.updatedEntries;
|
||||
this.renderStep();
|
||||
});
|
||||
round.appendChild(confirmBtn);
|
||||
|
||||
body.appendChild(round);
|
||||
},
|
||||
|
||||
recordRound(result) {
|
||||
result.perWinner.forEach(w => this.addToTotal(w.id, w.amount));
|
||||
this.log.push({
|
||||
amount: result.payoutPool,
|
||||
winners: result.perWinner.map(w => `${this.playerLabelById.get(w.id)} (${Money.format(w.amount)})`),
|
||||
});
|
||||
},
|
||||
|
||||
// The announcement, spelled out before anyone is asked to pick a winner: what the
|
||||
// main pot is worth, what each side pot is worth, and who can win each.
|
||||
renderBreakdown() {
|
||||
const node = Render.buildPotBreakdown(
|
||||
this.layers || [],
|
||||
this.returns || [],
|
||||
id => this.playerLabelById.get(id),
|
||||
"Announce before the showdown"
|
||||
);
|
||||
if (!node) return document.createDocumentFragment();
|
||||
node.classList.add("potBreakdownWizard");
|
||||
return node;
|
||||
},
|
||||
|
||||
renderLog() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardLog";
|
||||
if (this.log.length === 0) return wrap;
|
||||
|
||||
this.log.forEach((entry, idx) => {
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardLogLine";
|
||||
line.textContent = `Pot ${idx + 1}: ${Money.format(entry.amount)} → ${entry.winners.join(", ")}`;
|
||||
wrap.appendChild(line);
|
||||
});
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderSummary() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardSummary";
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "Final Payouts";
|
||||
wrap.appendChild(heading);
|
||||
|
||||
if (this.totals.size === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "fieldHint";
|
||||
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
const legend = (this.state && this.state.chipLegend) || [];
|
||||
const list = document.createElement("ul");
|
||||
for (const [id, amount] of this.totals.entries()) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||
|
||||
// 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", () => {
|
||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
this.close();
|
||||
this.onFinished(payouts);
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
// This button cancels — it does not pay anybody. Under a heading reading
|
||||
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
|
||||
// something already done, and a dealer who taps it walks away believing the
|
||||
// pot has been pushed. It hasn't: the chips are still in the middle and the
|
||||
// hand isn't logged. So it says what it does.
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "holdemBtn holdemBtnGhost";
|
||||
cancelBtn.textContent = "Cancel — don't pay yet";
|
||||
cancelBtn.addEventListener("click", () => this.close());
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
if (this.totals.size > 0) {
|
||||
const note = document.createElement("p");
|
||||
note.className = "fieldHint potWizardPendingNote";
|
||||
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(note);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(actions);
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = PotWizard;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
|
||||
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());
|
||||
@@ -0,0 +1,942 @@
|
||||
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
|
||||
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
|
||||
// re-draws everything (used after structural changes like resizing seats or the chip
|
||||
// legend).
|
||||
//
|
||||
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
|
||||
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
|
||||
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
|
||||
// full form in the seat editor panel instead.
|
||||
const Render = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
selectedSeatIndex: null,
|
||||
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.selectedSeatIndex = null;
|
||||
// A reset hands over a different table entirely, so nothing cached about the
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
renderAll() {
|
||||
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
|
||||
this.selectedSeatIndex = null;
|
||||
}
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.renderChipLegend();
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
document.getElementById("seatCountInput").value = this.state.seatCount;
|
||||
document.getElementById("directionSelect").value = this.state.direction;
|
||||
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
|
||||
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
},
|
||||
|
||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||
// generated from a touch screen, so on a phone the last clause of the hint below
|
||||
// described something impossible — on the device the rest of the sentence was
|
||||
// written for. Touch gets told about the menu route instead, which works.
|
||||
canDragSeats() {
|
||||
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
|
||||
},
|
||||
|
||||
renderTableHint() {
|
||||
const hint = document.getElementById("tableHint");
|
||||
// Gestures are invisible until someone tells you about them, and this is the
|
||||
// only place on the table with room to say so.
|
||||
hint.textContent = this.canDragSeats()
|
||||
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
|
||||
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
const notesEl = document.getElementById("tableNotes");
|
||||
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
|
||||
},
|
||||
|
||||
updatePotBadge() {
|
||||
// Chips still sitting in front of players on the current street are part of
|
||||
// the pot as far as anyone at the table is concerned, so count them — waiting
|
||||
// until the street closes would show a stale number for most of every hand.
|
||||
const swept = HoldemState.totalPot(this.state);
|
||||
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
|
||||
},
|
||||
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
|
||||
seatPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 44, 42);
|
||||
},
|
||||
|
||||
// Bets sit on the felt between their player and the pot, the way chips actually
|
||||
// do — closer in than the seats, on the same spoke. The bets layer is inset to
|
||||
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||||
betPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 27, 25);
|
||||
},
|
||||
|
||||
positionOnRing(index, count, rx, ry) {
|
||||
const angle = -90 + (360 / count) * index;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
left: 50 + rx * Math.cos(radians),
|
||||
top: 50 + ry * Math.sin(radians),
|
||||
};
|
||||
},
|
||||
|
||||
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||||
// gets bets that look like the chips actually being pushed out.
|
||||
chipColorFor(amount) {
|
||||
const legend = [...(this.state.chipLegend || [])]
|
||||
.filter(chip => Number(chip.value) > 0)
|
||||
.sort((a, b) => Number(b.value) - Number(a.value));
|
||||
const match = legend.find(chip => amount >= Number(chip.value));
|
||||
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
|
||||
},
|
||||
|
||||
// ---- Bets on the felt ----
|
||||
|
||||
makeBetChip(bet) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "betChip";
|
||||
|
||||
const disc = document.createElement("span");
|
||||
disc.className = "betChipDisc";
|
||||
disc.style.background = this.chipColorFor(bet);
|
||||
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "betChipAmount";
|
||||
amount.textContent = Money.format(bet);
|
||||
|
||||
chip.appendChild(disc);
|
||||
chip.appendChild(amount);
|
||||
return chip;
|
||||
},
|
||||
|
||||
// A phone's felt has no room for a third ring: measured every radius between the
|
||||
// seats and the pot badge and each one collides with one or the other. So on a
|
||||
// narrow screen the bet clings to its own puck instead — same chip, no ring —
|
||||
// sitting on whichever side faces the middle of the table.
|
||||
renderBets() {
|
||||
const layer = document.getElementById("betsLayer");
|
||||
if (!layer) return;
|
||||
layer.innerHTML = "";
|
||||
|
||||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const bet = Betting.committed(player);
|
||||
if (bet <= 0) return;
|
||||
|
||||
const chip = this.makeBetChip(bet);
|
||||
const pos = this.betPosition(index, this.state.seatCount);
|
||||
|
||||
if (anchorToSeat) {
|
||||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (!seat) return;
|
||||
const seatPos = this.seatPosition(index, this.state.seatCount);
|
||||
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||
seat.appendChild(chip);
|
||||
return;
|
||||
}
|
||||
|
||||
chip.style.left = `${pos.left}%`;
|
||||
chip.style.top = `${pos.top}%`;
|
||||
layer.appendChild(chip);
|
||||
});
|
||||
},
|
||||
|
||||
puckLabel(player, seatIndex) {
|
||||
return HoldemState.displayName(player, seatIndex);
|
||||
},
|
||||
|
||||
renderSeats() {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
layer.innerHTML = "";
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
const pos = this.seatPosition(index, this.state.seatCount);
|
||||
node.style.left = `${pos.left}%`;
|
||||
node.style.top = `${pos.top}%`;
|
||||
node.dataset.seatIndex = String(index);
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip
|
||||
// arrives from — the chip sits on the same spoke, closer in. For a seat on
|
||||
// the top of the ring that's from below; on the bottom, from above.
|
||||
//
|
||||
// The blind badge has to be on the other side or the chip lands on top of
|
||||
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
|
||||
// the badge's 288 square pixels, and it sits a layer higher, so the marker
|
||||
// telling the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Every one of these announced itself as "D", so a screen reader heard a row
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
// The label now says whose it is, or what pressing it would do.
|
||||
const who = (player.name || "").trim() || `seat ${index + 1}`;
|
||||
const chipLabel = hasButton
|
||||
? `${who} has the dealer button`
|
||||
: `Give the dealer button to ${who}`;
|
||||
dealerChip.setAttribute("aria-label", chipLabel);
|
||||
dealerChip.title = chipLabel;
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
dealerChip.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
this.hooks.onGiveButton(index);
|
||||
});
|
||||
|
||||
const sbBadge = node.querySelector(".sbBadge");
|
||||
const bbBadge = node.querySelector(".bbBadge");
|
||||
sbBadge.classList.toggle("visible", index === sbIndex);
|
||||
bbBadge.classList.toggle("visible", index === bbIndex);
|
||||
// A blind the dealer named by hand isn't where the button would have put
|
||||
// it, so it's marked — otherwise the table looks like it's just wrong.
|
||||
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
|
||||
// blinds are positional, and classList.toggle with an undefined second
|
||||
// argument *flips* the class instead of clearing it, so the marker
|
||||
// appeared and disappeared on alternate renders.
|
||||
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||||
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
this.wireSeatGestures(node, index);
|
||||
this.wireDragAndDrop(node, index);
|
||||
|
||||
layer.appendChild(node);
|
||||
});
|
||||
|
||||
this.renderBets();
|
||||
},
|
||||
|
||||
isRedSuit(card) {
|
||||
return /[HD]$/i.test(card || "");
|
||||
},
|
||||
|
||||
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
|
||||
|
||||
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
|
||||
// keeps working; the suit only becomes a symbol on its way to a human.
|
||||
formatCard(card) {
|
||||
const value = (card || "").toUpperCase();
|
||||
if (!value) return "";
|
||||
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
|
||||
},
|
||||
|
||||
// An empty slot has to look like something you can tap, so it gets a placeholder
|
||||
// rather than going blank.
|
||||
paintCardSlot(slot, card) {
|
||||
const value = (card || "").toUpperCase();
|
||||
slot.classList.toggle("cardRed", this.isRedSuit(value));
|
||||
slot.classList.toggle("cardSlotEmpty", !value);
|
||||
slot.textContent = value ? this.formatCard(value) : "+";
|
||||
},
|
||||
|
||||
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
|
||||
// Worth calling out, because at a short-handed table the open seats are what
|
||||
// the dealer is looking for.
|
||||
isEmptySeat(player) {
|
||||
const named = !!(player.name && player.name.trim());
|
||||
const contributing =
|
||||
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
|
||||
return !named && !player.inHand && !contributing;
|
||||
},
|
||||
|
||||
updatePuckContent(node, player, index) {
|
||||
const empty = this.isEmptySeat(player);
|
||||
const nameEl = node.querySelector(".seatPuckName");
|
||||
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
||||
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||||
|
||||
const cardsEl = node.querySelector(".seatPuckCards");
|
||||
cardsEl.innerHTML = "";
|
||||
(player.cards || []).filter(Boolean).forEach((card, i) => {
|
||||
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
||||
const span = document.createElement("span");
|
||||
if (this.isRedSuit(card)) span.className = "cardRed";
|
||||
span.textContent = this.formatCard(card);
|
||||
cardsEl.appendChild(span);
|
||||
});
|
||||
// Always this player's total for the hand. What they've just pushed out on this
|
||||
// street is shown as a chip on the felt instead — the puck used to flip between
|
||||
// the two meanings depending on the moment, which is a bad thing for a number
|
||||
// sitting next to a stack size to do.
|
||||
const handTotal = Betting.handTotal(player);
|
||||
const total = handTotal > 0
|
||||
? handTotal
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
const totalEl = node.querySelector(".seatPuckTotal");
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
|
||||
// A player with nothing behind used to leave this line blank, so the felt drew
|
||||
// an all-in exactly like a seat whose stack nobody had entered. On a table with
|
||||
// side pots that's the one distinction the dealer is working from — who can
|
||||
// still be bet at, and who is already committed for everything they have.
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const allIn = Betting.isAllIn(this.state, player);
|
||||
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
|
||||
stackEl.classList.toggle("seatPuckAllIn", allIn);
|
||||
this.fitAmount(stackEl);
|
||||
|
||||
node.classList.toggle("seatAllIn", allIn);
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||
node.classList.toggle("seatFolded", player.folded);
|
||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||
},
|
||||
|
||||
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const length = (el.textContent || "").length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
// (which live in a separate panel) never lose focus.
|
||||
refreshPuck(index) {
|
||||
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||||
},
|
||||
|
||||
// ---- Seat gestures ----
|
||||
//
|
||||
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
|
||||
// the quick menu. The single tap waits out the double-tap window before it
|
||||
// fires — a fifth of a second of latency on opening a form, in exchange for a
|
||||
// fold that doesn't also fling a sheet up over the table.
|
||||
|
||||
LONG_PRESS_MS: 500,
|
||||
DOUBLE_TAP_MS: 220,
|
||||
DRAG_SLOP_PX: 8,
|
||||
pendingTap: null,
|
||||
|
||||
wireSeatGestures(node, index) {
|
||||
let longPressTimer = null;
|
||||
let longPressFired = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("pointerdown", e => {
|
||||
longPressFired = false;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
cancelLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
longPressFired = true;
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
this.hooks.onSeatLongPress(index);
|
||||
}, this.LONG_PRESS_MS);
|
||||
node.classList.add("seatPressing");
|
||||
});
|
||||
|
||||
// Moving means they're dragging the seat, not holding it.
|
||||
node.addEventListener("pointermove", e => {
|
||||
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
}
|
||||
});
|
||||
|
||||
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
|
||||
node.addEventListener(type, () => {
|
||||
cancelLongPress();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
});
|
||||
node.addEventListener("dragstart", () => {
|
||||
cancelLongPress();
|
||||
this.cancelPendingTap();
|
||||
node.classList.remove("seatPressing");
|
||||
});
|
||||
|
||||
node.addEventListener("click", e => {
|
||||
// The long press already did something; don't also treat the release as a tap.
|
||||
if (longPressFired) {
|
||||
longPressFired = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.handleSeatTap(index);
|
||||
});
|
||||
},
|
||||
|
||||
cancelPendingTap() {
|
||||
if (this.pendingTap) {
|
||||
clearTimeout(this.pendingTap.timer);
|
||||
this.pendingTap = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleSeatTap(index) {
|
||||
if (this.pendingTap && this.pendingTap.index === index) {
|
||||
this.cancelPendingTap();
|
||||
this.hooks.onSeatDoubleTap(index);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelPendingTap();
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTap = null;
|
||||
this.toggleSeatEditor(index);
|
||||
}, this.DOUBLE_TAP_MS);
|
||||
this.pendingTap = { index, timer };
|
||||
},
|
||||
|
||||
// Tapping the open seat again closes it — on a phone the editor is a sheet over
|
||||
// the table, and the seat you just tapped is the closest thing to hand for
|
||||
// dismissing it.
|
||||
toggleSeatEditor(index) {
|
||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
},
|
||||
|
||||
wireDragAndDrop(node, index) {
|
||||
node.addEventListener("dragstart", e => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
node.classList.add("dragging");
|
||||
});
|
||||
node.addEventListener("dragend", () => node.classList.remove("dragging"));
|
||||
node.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
node.classList.add("dropTarget");
|
||||
});
|
||||
node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
|
||||
node.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
node.classList.remove("dropTarget");
|
||||
const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
|
||||
const targetIndex = index;
|
||||
if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
|
||||
const seats = this.state.seats;
|
||||
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
|
||||
this.renderSeatEditor();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Seat editor panel (the full form for whichever seat is selected) ----
|
||||
|
||||
closeSeatEditor() {
|
||||
this.selectedSeatIndex = null;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
},
|
||||
|
||||
renderSeatEditor() {
|
||||
const title = document.getElementById("seatEditorTitle");
|
||||
const body = document.getElementById("seatEditorBody");
|
||||
|
||||
// Drives the mobile bottom sheet: the panel only slides up over the table
|
||||
// while a seat is actually being edited.
|
||||
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
|
||||
|
||||
if (this.selectedSeatIndex === null) {
|
||||
title.textContent = "Select a seat";
|
||||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.selectedSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
title.textContent = this.puckLabel(player, index);
|
||||
|
||||
body.innerHTML = "";
|
||||
const template = document.getElementById("seatEditorTemplate");
|
||||
const form = template.content.firstElementChild.cloneNode(true);
|
||||
|
||||
this.wireSeatFields(form, player, index);
|
||||
this.renderChipCounts(form, player, index);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.renderSeatTotal(form, player);
|
||||
|
||||
body.appendChild(form);
|
||||
},
|
||||
|
||||
renderSeatTotal(form, player) {
|
||||
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
|
||||
},
|
||||
|
||||
commitSeatChange(form, player, index) {
|
||||
this.hooks.save(this.state);
|
||||
this.updatePotBadge();
|
||||
this.renderSeatTotal(form, player);
|
||||
this.refreshPuck(index);
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
const nameInput = form.querySelector(".seatNameInput");
|
||||
nameInput.value = player.name;
|
||||
nameInput.addEventListener("input", () => {
|
||||
player.name = nameInput.value;
|
||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const inHandCheckbox = form.querySelector(".inHandCheckbox");
|
||||
inHandCheckbox.checked = player.inHand;
|
||||
inHandCheckbox.addEventListener("change", () => {
|
||||
player.inHand = inHandCheckbox.checked;
|
||||
if (!player.inHand) player.folded = false;
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const foldCheckboxRef = form.querySelector(".foldCheckbox");
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
foldCheckboxRef.addEventListener("change", () => {
|
||||
player.folded = foldCheckboxRef.checked;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||
slots.forEach((slot, cardIdx) => {
|
||||
this.paintCardSlot(slot, player.cards[cardIdx]);
|
||||
slot.addEventListener("click", () => {
|
||||
CardPicker.open(this.state, {
|
||||
onPick: (seatIndex, pickedIdx, card) => {
|
||||
this.paintCardSlot(slots[pickedIdx], card);
|
||||
this.commitSeatChange(form, player, seatIndex);
|
||||
},
|
||||
}, index, cardIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// What they've handed over tonight, shown next to what's in front of them so a
|
||||
// cash-out is one subtraction rather than a memory test.
|
||||
const boughtIn = Number(player.buyIns) || 0;
|
||||
const boughtInEl = form.querySelector(".seatBuyIns");
|
||||
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
|
||||
boughtInEl.hidden = boughtIn <= 0;
|
||||
|
||||
const stackInput = form.querySelector(".stackInput");
|
||||
stackInput.value = player.stack ?? "";
|
||||
stackInput.addEventListener("input", () => {
|
||||
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const amountInput = form.querySelector(".amountOverrideInput");
|
||||
amountInput.value = player.amountOverride ?? "";
|
||||
amountInput.addEventListener("input", () => {
|
||||
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const addFieldBtn = form.querySelector(".addFieldBtn");
|
||||
addFieldBtn.addEventListener("click", () => {
|
||||
player.customFields.push({ label: "", value: "" });
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
},
|
||||
|
||||
renderChipCounts(form, player, index) {
|
||||
const container = form.querySelector(".chipCounts");
|
||||
container.innerHTML = "";
|
||||
const template = document.getElementById("chipCountTemplate");
|
||||
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||||
row.title = `$${chip.value} chips`;
|
||||
const input = row.querySelector(".chipCountInput");
|
||||
input.value = player.chipCounts[chip.id] || 0;
|
||||
input.addEventListener("input", () => {
|
||||
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
|
||||
player.chipCounts[chip.id] = count;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
renderCustomFields(form, player, index) {
|
||||
const container = form.querySelector(".customFields");
|
||||
container.innerHTML = "";
|
||||
const template = document.getElementById("customFieldTemplate");
|
||||
|
||||
player.customFields.forEach((field, fieldIdx) => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const labelInput = row.querySelector(".customFieldLabel");
|
||||
const valueInput = row.querySelector(".customFieldValue");
|
||||
labelInput.value = field.label;
|
||||
valueInput.value = field.value;
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
field.value = valueInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Chip legend ----
|
||||
|
||||
renderChipLegend() {
|
||||
const list = document.getElementById("chipLegendList");
|
||||
list.innerHTML = "";
|
||||
const template = document.getElementById("chipLegendRowTemplate");
|
||||
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const colorInput = row.querySelector(".chipColorInput");
|
||||
const valueInput = row.querySelector(".chipValueInput");
|
||||
colorInput.value = chip.color;
|
||||
valueInput.value = chip.value;
|
||||
|
||||
colorInput.addEventListener("input", () => {
|
||||
chip.color = colorInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
chip.value = Number(valueInput.value) || 0;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.updatePotBadge();
|
||||
});
|
||||
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
|
||||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
||||
this.hooks.save(this.state);
|
||||
this.renderAll();
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Pot breakdown ----
|
||||
|
||||
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
|
||||
// picked), because both are showing the dealer the same announcement.
|
||||
buildPotBreakdown(pots, returns, nameFor, heading) {
|
||||
if (pots.length < 2 && returns.length === 0) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potBreakdown";
|
||||
|
||||
if (heading) {
|
||||
const title = document.createElement("p");
|
||||
title.className = "potBreakdownHeading";
|
||||
title.textContent = heading;
|
||||
wrap.appendChild(title);
|
||||
}
|
||||
|
||||
const addRow = (label, amount, who, className) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `potLayer ${className || ""}`;
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "potLayerLabel";
|
||||
labelEl.textContent = label;
|
||||
|
||||
const amountEl = document.createElement("span");
|
||||
amountEl.className = "potLayerAmount";
|
||||
amountEl.textContent = Money.format(amount);
|
||||
|
||||
const whoEl = document.createElement("span");
|
||||
whoEl.className = "potLayerWho";
|
||||
whoEl.textContent = who;
|
||||
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(amountEl);
|
||||
row.appendChild(whoEl);
|
||||
wrap.appendChild(row);
|
||||
};
|
||||
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// ---- Session standings ----
|
||||
|
||||
renderStandings() {
|
||||
const list = document.getElementById("standingsList");
|
||||
const emptyHint = document.getElementById("standingsEmptyHint");
|
||||
const table = document.getElementById("standingsTable");
|
||||
const standings = HoldemState.sessionStandings(this.state);
|
||||
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = standings.length > 0;
|
||||
table.hidden = standings.length === 0;
|
||||
|
||||
const template = document.getElementById("standingsRowTemplate");
|
||||
standings.forEach(row => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".standingsName").textContent = row.name;
|
||||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||||
|
||||
const netEl = node.querySelector(".standingsNet");
|
||||
netEl.textContent = Money.formatSigned(row.net);
|
||||
netEl.classList.toggle("netUp", row.net > 0);
|
||||
netEl.classList.toggle("netDown", row.net < 0);
|
||||
|
||||
// What they'd actually be handed if the night ended now: chips in front of
|
||||
// them less the cash they gave the banker. This was computed and written to
|
||||
// the CSV all along, but never shown to the person doing the paying out.
|
||||
const cashEl = node.querySelector(".standingsCashOut");
|
||||
cashEl.textContent = Money.formatSigned(row.cashPosition);
|
||||
cashEl.classList.toggle("netUp", row.cashPosition > 0);
|
||||
cashEl.classList.toggle("netDown", row.cashPosition < 0);
|
||||
|
||||
list.appendChild(node);
|
||||
});
|
||||
|
||||
this.renderBankReconciliation();
|
||||
this.renderSettleUp(standings);
|
||||
this.renderStorageWarning();
|
||||
},
|
||||
|
||||
// A toast is a moment; this table not being saved is a fact about the rest of the
|
||||
// night. It sits at the top of the panel a dealer opens to check the money, and it
|
||||
// stays there, because the raise pad dismisses toasts and the one time this warning
|
||||
// matters is the one time it must not have been missed.
|
||||
renderStorageWarning() {
|
||||
const host = document.getElementById("storageWarning");
|
||||
if (!host) return;
|
||||
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
|
||||
if (host.hidden) return;
|
||||
|
||||
// Any of these can be true at once — a browser having trouble with storage
|
||||
// tends to have it in both directions — and the one that explains an empty
|
||||
// table goes first, because that's the question the dealer is standing there
|
||||
// asking.
|
||||
const lines = [];
|
||||
if (HoldemState.otherTabActive) {
|
||||
lines.push(
|
||||
"This table is open in another tab, and both are writing to it — " +
|
||||
"whichever acts last wins, so work done in the other one can vanish. " +
|
||||
"Close one, then reload this tab to pick up the table as it stands.");
|
||||
}
|
||||
if (HoldemState.loadFailed) {
|
||||
lines.push(
|
||||
"The saved table couldn't be read, so this one started empty. " +
|
||||
"The unreadable copy has been kept rather than written over.");
|
||||
}
|
||||
if (HoldemState.saveFailed) {
|
||||
lines.push(
|
||||
"This table isn't being saved — the browser refused. It'll keep working, " +
|
||||
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
|
||||
}
|
||||
host.textContent = lines.join(" ");
|
||||
},
|
||||
|
||||
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
|
||||
// and it's the part of the night people actually get wrong.
|
||||
renderSettleUp(standings) {
|
||||
const host = document.getElementById("settleUp");
|
||||
if (!host) return;
|
||||
|
||||
const plan = SettleUp.plan(standings);
|
||||
const note = document.getElementById("settleUpNote");
|
||||
const list = document.getElementById("settleUpList");
|
||||
|
||||
// Nothing to show before anyone is up or down. A panel that says "no payments"
|
||||
// to a table that hasn't played a hand is just noise in the sidebar.
|
||||
host.hidden = plan.payments.length === 0 && plan.balanced;
|
||||
if (host.hidden) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
plan.payments.forEach(payment => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "settleUpRow";
|
||||
|
||||
const who = document.createElement("span");
|
||||
who.className = "settleUpWho";
|
||||
// An arrow rather than "pays": the direction is the whole content of the
|
||||
// line, and it survives being read at a glance from across the table.
|
||||
who.textContent = `${payment.from} → ${payment.to}`;
|
||||
row.appendChild(who);
|
||||
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "settleUpAmount";
|
||||
amount.textContent = Money.format(payment.amount);
|
||||
row.appendChild(amount);
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
if (plan.balanced) {
|
||||
const count = plan.payments.length;
|
||||
note.textContent = count === 1
|
||||
? "One payment and the night is square."
|
||||
: `${count} payments and the night is square.`;
|
||||
} else if (plan.shortBy > 0) {
|
||||
note.textContent =
|
||||
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
||||
} else {
|
||||
note.textContent =
|
||||
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||
}
|
||||
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||
},
|
||||
|
||||
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||
// it's the question that decides whether the last two players can be paid.
|
||||
renderBankReconciliation() {
|
||||
const host = document.getElementById("bankReconcile");
|
||||
if (!host) return;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(this.state);
|
||||
// Nothing to reconcile before anybody has handed over money or taken a chip.
|
||||
host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
|
||||
if (host.hidden) return;
|
||||
|
||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
} else {
|
||||
verdict.textContent =
|
||||
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
||||
}
|
||||
verdict.classList.toggle("bankBalanced", bank.balanced);
|
||||
verdict.classList.toggle("bankOff", !bank.balanced);
|
||||
},
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
// toLocaleTimeString is the single most expensive call in the history panel, and a
|
||||
// logged hand's timestamp never changes, so the answer is worth keeping.
|
||||
handTimeCache: new Map(),
|
||||
|
||||
formatHandTime(timestamp) {
|
||||
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
|
||||
let formatted = "";
|
||||
try {
|
||||
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
formatted = "";
|
||||
}
|
||||
this.handTimeCache.set(timestamp, formatted);
|
||||
return formatted;
|
||||
},
|
||||
|
||||
// Cheap stand-in for "has the log changed". Entries are immutable once written and
|
||||
// only ever added at the front, so the length, the counter and the newest row
|
||||
// between them catch a recorded hand, a cleared history and an undo.
|
||||
historySignature() {
|
||||
const history = this.state.handHistory || [];
|
||||
const newest = history[0];
|
||||
return [
|
||||
history.length,
|
||||
this.state.handNumber || 1,
|
||||
newest ? newest.handNumber : "",
|
||||
newest ? newest.timestamp : "",
|
||||
newest ? newest.pot : "",
|
||||
].join("|");
|
||||
},
|
||||
|
||||
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||
// is skipped unless it actually did change. Everything else on the table reflects
|
||||
// the current hand and still redraws every time.
|
||||
renderHandHistory(force = false) {
|
||||
const signature = this.historySignature();
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
// Built off-document so the panel is laid out once instead of once per row.
|
||||
const fragment = document.createDocumentFragment();
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
||||
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
||||
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
|
||||
|
||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
list.innerHTML = "";
|
||||
list.appendChild(fragment);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Render };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||
// one press and one tap away, without opening the full editor and without hunting
|
||||
// for whose turn it is in the action bar.
|
||||
const SeatMenu = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
|
||||
mode: null,
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
// Always the action list, never whatever the sheet was showing when it last
|
||||
// closed — a seat menu that opens on a list of chairs is a menu that lost the
|
||||
// dealer's place.
|
||||
this.mode = null;
|
||||
this.render();
|
||||
document.getElementById("seatMenuOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("seatMenuOverlay").hidden = true;
|
||||
},
|
||||
|
||||
// Only the actions that make sense for this seat right now — a folded player has
|
||||
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||
actionsFor(player, index) {
|
||||
const actions = [];
|
||||
const dealtIn = player.inHand;
|
||||
const live = dealtIn && !player.folded;
|
||||
|
||||
if (live) {
|
||||
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
} else if (dealtIn && player.folded) {
|
||||
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
}
|
||||
|
||||
// The most common interruption in a cash game, and until now it meant opening
|
||||
// the editor and retyping a stack from memory.
|
||||
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
|
||||
|
||||
actions.push({
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||
// once somebody has called, moving a blind would mean unpicking a bet that
|
||||
// has already been answered, and that's a decision for the dealer's mouth,
|
||||
// not a menu item. Which blind a seat already has is shown rather than
|
||||
// offered again.
|
||||
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||
const blinds = HoldemState.getBlindSeats(this.state);
|
||||
if (blinds.sbIndex !== index) {
|
||||
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
}
|
||||
if (blinds.bbIndex !== index) {
|
||||
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
}
|
||||
if (this.state.blindOverride) {
|
||||
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||
}
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
// The chairs this player could move to. Every other chair: swapping into an empty
|
||||
// one is how a player moves, and swapping with an occupied one is two players
|
||||
// changing places, which is the other half of the same job.
|
||||
destinationsFor(index) {
|
||||
return this.state.seats
|
||||
.map((player, seat) => ({ player, seat }))
|
||||
.filter(entry => entry.seat !== index);
|
||||
},
|
||||
|
||||
openMovePicker() {
|
||||
this.mode = "move";
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this.mode === "move") {
|
||||
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "seatMenuBtn";
|
||||
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||
const named = (other.name || "").trim();
|
||||
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||
btn.addEventListener("click", () => {
|
||||
const from = this.seatIndex;
|
||||
this.close();
|
||||
this.hooks.swapSeats(from, seat);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
subtitle.textContent = bits.join(" · ");
|
||||
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||
// other action is the end of the interaction.
|
||||
if (action.label === "Move to…") {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SeatMenu;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Who hands cash to whom at the end of the night.
|
||||
//
|
||||
// The standings panel already works out what everyone is up or down. It then stopped,
|
||||
// which leaves the dealer with six signed numbers at 2am and the actual job still to
|
||||
// do: turning them into payments. That's the step people get wrong — not the poker.
|
||||
//
|
||||
// Everything is done in cents. A settlement that has to come out exactly right is the
|
||||
// last place to let a float land on 41.99999999.
|
||||
const SettleUp = {
|
||||
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
|
||||
// and for this problem that's the right trade: finding the true minimum number of
|
||||
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
|
||||
// than there are people, and each payment it does emit clears somebody out of the
|
||||
// list entirely. A dealer wants a short list they can read down, not a proof.
|
||||
//
|
||||
// Payers are the outer loop so that everything one person owes is printed together
|
||||
// — the list is read out loud, one player at a time.
|
||||
plan(standings) {
|
||||
const owed = [];
|
||||
const owing = [];
|
||||
|
||||
(standings || []).forEach(row => {
|
||||
const cents = Money.toCents(row.cashPosition);
|
||||
if (cents > 0) owed.push({ name: row.name, cents });
|
||||
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
|
||||
});
|
||||
|
||||
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
|
||||
const payments = [];
|
||||
let creditor = 0;
|
||||
owing.forEach(payer => {
|
||||
while (payer.cents > 0 && creditor < owed.length) {
|
||||
const taker = owed[creditor];
|
||||
const amount = Math.min(payer.cents, taker.cents);
|
||||
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
|
||||
payer.cents -= amount;
|
||||
taker.cents -= amount;
|
||||
if (taker.cents === 0) creditor++;
|
||||
}
|
||||
});
|
||||
|
||||
// What's left over when the chips on the table don't match the cash that came
|
||||
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
|
||||
// is always the thing the bank check is already warning about: a rebuy nobody
|
||||
// wrote down, or a miscount. Reported rather than quietly spread across the
|
||||
// payments, because inventing a transfer to make the columns agree is how a
|
||||
// real person ends up short.
|
||||
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
|
||||
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
|
||||
|
||||
return {
|
||||
payments,
|
||||
// Only one of these can be non-zero: either there are more chips than cash
|
||||
// or less, never both.
|
||||
shortBy: unpaid,
|
||||
overBy: unspent,
|
||||
balanced: unpaid === 0 && unspent === 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SettleUp;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Keyboard control for a dealer running this on a laptop.
|
||||
//
|
||||
// The phone has gestures; the desktop had a mouse and nothing else. These are the
|
||||
// same actions the bar already offers, reachable without moving a hand to the
|
||||
// trackpad between every decision — which is the whole point when the bar is being
|
||||
// tapped thirty times a hand.
|
||||
const Shortcuts = {
|
||||
// Deliberately single letters with no modifier: a dealer's spare hand rests on
|
||||
// the keyboard, and a chord is slower than reaching for the button would be.
|
||||
KEYS: {
|
||||
f: "fold",
|
||||
c: "checkOrCall",
|
||||
r: "raise",
|
||||
a: "allIn",
|
||||
" ": "advance",
|
||||
enter: "advance",
|
||||
u: "undo",
|
||||
escape: "cancel",
|
||||
},
|
||||
|
||||
// What each one does, in a dealer's words, for the legend in the sidebar.
|
||||
//
|
||||
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
|
||||
// buttons they drive, but A and U were on nothing at all — a dealer who learned
|
||||
// the first three from the bar had no way to find out the other two existed. A
|
||||
// shortcut with no line here is a shortcut nobody can find, and there's a test
|
||||
// that says so.
|
||||
LEGEND: [
|
||||
{ keys: ["F"], action: "fold", what: "Fold" },
|
||||
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||
],
|
||||
|
||||
mount(handlers) {
|
||||
this.handlers = handlers;
|
||||
document.addEventListener("keydown", e => this.onKey(e));
|
||||
},
|
||||
|
||||
// Nothing fires while the dealer is typing a name, an amount, or a note — the
|
||||
// "c" in "Cass" must not fold the table.
|
||||
isTyping() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
},
|
||||
|
||||
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||
// are open, rather than listing them, means a sheet added later can't quietly
|
||||
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||
// them instead of being told about each one.
|
||||
overlayOpen() {
|
||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||
},
|
||||
|
||||
// Enter and Space are how a keyboard presses the control it is already sitting on.
|
||||
// Claiming them for "advance" didn't just block that press — it ran a different
|
||||
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
|
||||
// the next hand. Space on the same button worked, because "advance" happened to
|
||||
// decline that time and the browser's own activation got through. A control that
|
||||
// does something else every other press is worse than one that does nothing.
|
||||
//
|
||||
// Only these two keys are handed back. The letter shortcuts stay live with a
|
||||
// button focused, because a button does nothing with "f" and the dealer's hand
|
||||
// shouldn't have to leave them.
|
||||
focusedControlOwnsKey(key) {
|
||||
if (key !== "enter" && key !== " ") return false;
|
||||
const el = document.activeElement;
|
||||
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
||||
const action = this.KEYS[key];
|
||||
if (!action) return;
|
||||
|
||||
if (this.focusedControlOwnsKey(key)) return;
|
||||
|
||||
// Escape is the exception: it should still close the raise pad even if focus
|
||||
// happens to be sitting in its amount field.
|
||||
if (this.isTyping() && !(action === "cancel")) return;
|
||||
if (this.overlayOpen()) return;
|
||||
|
||||
const handled = this.handlers[action] && this.handlers[action]();
|
||||
if (handled) e.preventDefault();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Shortcuts;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Pure math for chopping one pot between tied players. No DOM.
|
||||
//
|
||||
// A dealer can't push half a chip across the felt, so this works in whole chips of
|
||||
// whatever the smallest denomination in play is: everyone gets the same stack, and
|
||||
// whatever won't divide is called out separately as odd chips. House rule for those
|
||||
// is that they go to the first player left of the button, one at a time.
|
||||
const SplitCalculator = {
|
||||
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||
toCents(amount) {
|
||||
return Money.toCents(amount);
|
||||
},
|
||||
|
||||
split(potAmount, ways, chipAmount) {
|
||||
const potCents = this.toCents(potAmount);
|
||||
const chipCents = Math.max(1, this.toCents(chipAmount));
|
||||
const players = Math.max(1, Math.floor(Number(ways) || 0));
|
||||
|
||||
if (potCents <= 0) {
|
||||
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
|
||||
}
|
||||
|
||||
const wholeChips = Math.floor(potCents / chipCents);
|
||||
// Left over when the pot itself isn't a whole number of the smallest chip —
|
||||
// usually means the dealer picked the wrong denomination, so it's surfaced
|
||||
// rather than silently rolled into someone's stack.
|
||||
const dustCents = potCents - wholeChips * chipCents;
|
||||
|
||||
const chipsEach = Math.floor(wholeChips / players);
|
||||
const oddChips = wholeChips - chipsEach * players;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
players,
|
||||
each: (chipsEach * chipCents) / 100,
|
||||
oddChips,
|
||||
chip: chipCents / 100,
|
||||
dust: dustCents / 100,
|
||||
};
|
||||
},
|
||||
|
||||
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||
accountedFor(result) {
|
||||
return Money.fromCents(
|
||||
Money.toCents(result.each) * result.players +
|
||||
Money.toCents(result.chip) * result.oddChips +
|
||||
Money.toCents(result.dust)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SplitCalculator;
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
// Table state: seats, chips, blinds, notes. Persisted to localStorage so a dealer's
|
||||
// browser refresh mid-game doesn't lose the table.
|
||||
const HoldemState = {
|
||||
STORAGE_KEY: "casino.holdem.state.v1",
|
||||
DEFAULT_SEAT_COUNT: 10,
|
||||
|
||||
DEFAULT_CHIP_LEGEND: [
|
||||
{ id: "white", color: "#f5f5f5", value: 1 },
|
||||
{ id: "red", color: "#c0392b", value: 5 },
|
||||
{ id: "green", color: "#1e8449", value: 25 },
|
||||
{ id: "black", color: "#111111", value: 100 },
|
||||
{ id: "purple", color: "#6c3483", value: 500 },
|
||||
],
|
||||
|
||||
makeId(prefix) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
},
|
||||
|
||||
makePlayer() {
|
||||
return {
|
||||
id: this.makeId("player"),
|
||||
name: "",
|
||||
inHand: false,
|
||||
folded: false,
|
||||
cards: ["", ""],
|
||||
chipCounts: {},
|
||||
amountOverride: null,
|
||||
customFields: [],
|
||||
// Live-betting fields: chips in front of the player, chips they've put in
|
||||
// on the current street, and whether they've answered the current bet.
|
||||
stack: null,
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
// Cash handed over tonight, which only moves when they buy chips.
|
||||
buyIns: 0,
|
||||
};
|
||||
},
|
||||
|
||||
MAX_HISTORY_ENTRIES: 200,
|
||||
|
||||
createDefault() {
|
||||
const seatCount = this.DEFAULT_SEAT_COUNT;
|
||||
return {
|
||||
seatCount,
|
||||
direction: "clockwise",
|
||||
basePotAmount: null,
|
||||
dealerSeatIndex: 0,
|
||||
dedicatedDealer: false,
|
||||
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
|
||||
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
|
||||
notes: "",
|
||||
handNumber: 1,
|
||||
handHistory: [],
|
||||
smallBlind: 1,
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
street: "preflop",
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
// Whether the betting has actually been opened for this hand.
|
||||
//
|
||||
// Dealing and betting used to be one button: "Next Hand" moved the button
|
||||
// and posted the blinds in the same breath. That meant the moment between
|
||||
// hands — the one where people are still sitting down, standing up and
|
||||
// arguing about whose turn it is — was spent with money already committed,
|
||||
// and the blind badges recomputing off `inHand` while the posted chips
|
||||
// stayed frozen from when the hand began. Seat somebody after pressing it
|
||||
// and the table would show a big blind who had posted nothing.
|
||||
//
|
||||
// So the dealer says when. False means the table is being set up: nothing
|
||||
// is committed, the badges are a preview, and seating or moving the button
|
||||
// costs nothing.
|
||||
bettingOpen: false,
|
||||
// Set when the dealer names the blinds by hand instead of letting them
|
||||
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||
// ("you had it last time", someone arriving late owing a blind), and the
|
||||
// button order is the wrong answer often enough that it has to be
|
||||
// overridable. Cleared at the start of every hand.
|
||||
blindOverride: null,
|
||||
};
|
||||
},
|
||||
|
||||
// Where an unreadable save is kept instead of being written over.
|
||||
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
||||
|
||||
// True once another tab has written to this table. Two tabs each hold their own
|
||||
// copy in memory and each save writes the whole thing, so the second one to act
|
||||
// silently throws the first one's work away — measured: tab A deals and takes a
|
||||
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
||||
// nothing said in either tab.
|
||||
//
|
||||
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
||||
// would be its own hazard; knowing there are two of them is what they need.
|
||||
otherTabActive: false,
|
||||
onOtherTab: null,
|
||||
|
||||
// The browser fires this in every *other* tab, never the one that wrote, so our
|
||||
// own saves can't trip it.
|
||||
watchOtherTabs() {
|
||||
if (typeof window === "undefined" || this.tabWatcher) return;
|
||||
this.tabWatcher = event => {
|
||||
if (event.key !== this.STORAGE_KEY) return;
|
||||
if (this.otherTabActive) return;
|
||||
this.otherTabActive = true;
|
||||
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
||||
};
|
||||
window.addEventListener("storage", this.tabWatcher);
|
||||
},
|
||||
|
||||
// True when there was a saved table and it couldn't be read. Same pair as
|
||||
// saveFailed above, for the same reason: the UI has to be able to say so.
|
||||
loadFailed: false,
|
||||
onLoadFailed: null,
|
||||
|
||||
// A saved table that won't parse becomes an empty table, which is the only thing
|
||||
// it can become — but it used to become one silently, and then the first edit
|
||||
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
||||
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
||||
// a person might have picked out of the raw JSON was gone a tap later.
|
||||
//
|
||||
// So the unreadable value is moved aside first. It is not offered as a restore —
|
||||
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
||||
// an empty one — but it stops being destroyed, and somebody who wants the
|
||||
// standings out of it still can.
|
||||
failedLoad(raw) {
|
||||
if (!this.loadFailed) {
|
||||
this.loadFailed = true;
|
||||
try {
|
||||
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||
} catch {
|
||||
// No room to keep it. Nothing more to try, and the warning below is
|
||||
// the part that actually matters.
|
||||
}
|
||||
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
||||
}
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
|
||||
// Tables saved before live betting existed are missing its fields; fill
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||
"actingSeatIndex", "blindOverride"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
// guessing false would show "deal the hand" over a hand already in
|
||||
// progress. Anything already committed, or anyone already on the clock,
|
||||
// means the betting was open when they closed the tab.
|
||||
if (parsed.bettingOpen === undefined) {
|
||||
parsed.bettingOpen =
|
||||
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
|
||||
parsed.seats.some(p =>
|
||||
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
|
||||
}
|
||||
parsed.seats.forEach(player => {
|
||||
if (player.stack === undefined) player.stack = null;
|
||||
if (player.committed === undefined) player.committed = 0;
|
||||
if (player.hasActed === undefined) player.hasActed = false;
|
||||
if (player.buyIns === undefined) player.buyIns = 0;
|
||||
});
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||
// toast is a moment and this is a fact about the rest of the night.
|
||||
saveFailed: false,
|
||||
|
||||
// Called the first time a save fails. Set by the UI.
|
||||
onSaveFailed: null,
|
||||
|
||||
save(state) {
|
||||
try {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Storage full, or private browsing. The table still works for the rest of
|
||||
// the session — it just won't survive a refresh, and nothing used to say
|
||||
// so. That is the same shape of fault the bank check exists for: silent
|
||||
// until it is too late to do anything about it. A dealer who finds out by
|
||||
// refreshing has lost the night's standings, and the only warning they got
|
||||
// was the table being empty.
|
||||
//
|
||||
// Announced once. A dealer tapping a bar thirty times a hand does not need
|
||||
// thirty of these.
|
||||
if (!this.saveFailed) {
|
||||
this.saveFailed = true;
|
||||
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
resizeSeats(state, newCount) {
|
||||
const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
|
||||
const seats = state.seats.slice(0, count);
|
||||
while (seats.length < count) seats.push(this.makePlayer());
|
||||
state.seats = seats;
|
||||
state.seatCount = count;
|
||||
if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
|
||||
return state;
|
||||
},
|
||||
|
||||
// What to call a seat in logs, payouts and standings when the dealer hasn't
|
||||
// typed a name in yet.
|
||||
displayName(player, seatIndex) {
|
||||
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// Contribution the table calculator will treat this player as having put in.
|
||||
//
|
||||
// Chips already pushed into the middle count whether or not the player is still
|
||||
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
||||
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
||||
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
||||
// how those chips used to disappear out of the pot the instant a seat was
|
||||
// switched off, quietly shrinking the payout by whatever they had posted.
|
||||
computeContribution(player, chipLegend, basePotAmount) {
|
||||
const override = player.amountOverride;
|
||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||
const parsed = Number(override);
|
||||
if (!Number.isNaN(parsed)) return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
const chipTotal = chipLegend.reduce((sum, chip) => {
|
||||
const count = Number(player.chipCounts[chip.id]) || 0;
|
||||
return sum + count * Number(chip.value || 0);
|
||||
}, 0);
|
||||
if (chipTotal > 0) return chipTotal;
|
||||
|
||||
// The table-wide default is the one thing that stays tied to being dealt in:
|
||||
// it's a stand-in for "everyone put the same amount in", so applying it to
|
||||
// empty chairs would invent a contribution from every seat nobody is using.
|
||||
if (!player.inHand) return 0;
|
||||
|
||||
const base = Number(basePotAmount);
|
||||
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||
},
|
||||
|
||||
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
||||
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
||||
// pot but can never be picked as a winner, exactly like a fold.
|
||||
isDeadMoney(player) {
|
||||
return !player.inHand;
|
||||
},
|
||||
|
||||
// What starting the next hand would take off the table for good: exactly the
|
||||
// fields startNextHand clears.
|
||||
//
|
||||
// Deliberately not totalPot(). The table-wide base pot is a standing ante — a
|
||||
// stand-in for "everyone puts the same in every hand", used by a dealer who
|
||||
// isn't tracking stacks — so it re-applies to the next hand rather than being
|
||||
// lost with this one. Counting it would mean warning about money that isn't
|
||||
// going anywhere, on every single hand, for exactly the dealers who chose the
|
||||
// simplest way to run the table.
|
||||
unresolvedContributions(state) {
|
||||
const total = state.seats.reduce((sum, player) => {
|
||||
const override = player.amountOverride;
|
||||
let banked = 0;
|
||||
|
||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||
const parsed = Number(override);
|
||||
if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
|
||||
}
|
||||
|
||||
if (banked === 0) {
|
||||
banked = (state.chipLegend || []).reduce((chips, chip) => {
|
||||
const count = Number(player.chipCounts[chip.id]) || 0;
|
||||
return chips + count * Number(chip.value || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return sum + banked + Math.max(0, Number(player.committed) || 0);
|
||||
}, 0);
|
||||
|
||||
return Money.round(total);
|
||||
},
|
||||
|
||||
totalPot(state) {
|
||||
return state.seats.reduce(
|
||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
0
|
||||
);
|
||||
},
|
||||
|
||||
// Next occupied-by-index seat in the current blind direction. Every seat index
|
||||
// counts (there's no separate "empty chair" concept) — a dealer running fewer
|
||||
// players just keeps the seat count matched to who's actually sitting down.
|
||||
nextSeatIndex(state, fromIndex, step = 1) {
|
||||
const count = state.seatCount;
|
||||
const delta = state.direction === "counterclockwise" ? -step : step;
|
||||
return ((fromIndex + delta) % count + count) % count;
|
||||
},
|
||||
|
||||
// The button passes to the next player, not the next chair. Moving it one seat at
|
||||
// a time parks it on empty chairs — with four players spread around ten seats it
|
||||
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
||||
// supposed to come round to each player at the same rate, and they don't if the
|
||||
// button is rotating past chairs nobody is sitting in.
|
||||
advanceButton(state) {
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) {
|
||||
state.dealerSeatIndex = index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
|
||||
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
},
|
||||
|
||||
// Seats dealt into this hand, in blind order starting from the seat after the
|
||||
// button. Blinds follow the players, not the chairs — the seats immediately left
|
||||
// of the button are empty often enough that counting chairs posts them to nobody.
|
||||
liveOrderFromButton(state) {
|
||||
const order = [];
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) order.push(index);
|
||||
}
|
||||
return order;
|
||||
},
|
||||
|
||||
// A button sitting on an empty chair is only cosmetic until you try to read
|
||||
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||
// whether or not anybody is sitting there, and a table that empties out around it
|
||||
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||
// Where the button will be when the hand is dealt — without moving it.
|
||||
//
|
||||
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
|
||||
// to ask the question without answering it. It couldn't, and so the preview lied:
|
||||
// with the button parked on an empty chair it promised one pair of blinds and
|
||||
// dealing produced another, because beginHand relocates the button first and then
|
||||
// works out who posts.
|
||||
buttonSeatForDeal(state) {
|
||||
const seated = state.seats[state.dealerSeatIndex];
|
||||
if (seated && seated.inHand) return state.dealerSeatIndex;
|
||||
for (let step = 0; step < state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) return index;
|
||||
}
|
||||
return state.dealerSeatIndex;
|
||||
},
|
||||
|
||||
ensureButtonOnPlayer(state) {
|
||||
state.dealerSeatIndex = this.buttonSeatForDeal(state);
|
||||
},
|
||||
|
||||
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||
// dealer who names a blind and then deals that player out shouldn't have the
|
||||
// table quietly post to nobody.
|
||||
validBlindOverride(state) {
|
||||
const o = state.blindOverride;
|
||||
if (!o) return null;
|
||||
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
||||
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
||||
return o;
|
||||
},
|
||||
|
||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||
// small blind, which is the opposite of the usual order.
|
||||
getBlindSeats(state) {
|
||||
const live = this.liveOrderFromButton(state);
|
||||
const dealerIndex = state.dealerSeatIndex;
|
||||
|
||||
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
||||
// still reported honestly so the betting order stays right — who posts what
|
||||
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
||||
const override = this.validBlindOverride(state);
|
||||
if (override) {
|
||||
return {
|
||||
dealerIndex,
|
||||
sbIndex: override.sbIndex,
|
||||
bbIndex: override.bbIndex,
|
||||
headsUp: live.length === 2,
|
||||
manual: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (live.length < 2) {
|
||||
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
||||
}
|
||||
|
||||
if (live.length === 2) {
|
||||
const buttonIsPlaying = state.seats[dealerIndex].inHand;
|
||||
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
|
||||
const bbIndex = live.find(i => i !== sbIndex);
|
||||
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
||||
}
|
||||
|
||||
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
||||
},
|
||||
|
||||
startNextHand(state) {
|
||||
this.advanceButton(state);
|
||||
// Cards aren't out and nothing is posted until the dealer says so.
|
||||
state.bettingOpen = false;
|
||||
state.actingSeatIndex = null;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||
// the next one would freeze the blinds on two players for the rest of the
|
||||
// night, which is the opposite of what naming them once is for.
|
||||
state.blindOverride = null;
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
p.chipCounts = {};
|
||||
p.amountOverride = null;
|
||||
p.committed = 0;
|
||||
p.hasActed = false;
|
||||
});
|
||||
// Anyone who joined mid-hand is a full player again from here.
|
||||
this.ensureButtonOnPlayer(state);
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
// for chip-count disputes, then advances the hand counter. `payouts` is
|
||||
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
||||
//
|
||||
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
||||
// it stores alongside the payouts is what session standings are summed from later,
|
||||
// and it needs this hand's contributions while they're still on the table.
|
||||
recordHand(state, payouts) {
|
||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||
|
||||
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
||||
const ledger = state.seats
|
||||
.map((player, index) => ({
|
||||
id: player.id,
|
||||
name: this.displayName(player, index),
|
||||
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
|
||||
won: wonById.get(player.id) || 0,
|
||||
}))
|
||||
.filter(row => row.in > 0 || row.won > 0);
|
||||
|
||||
state.handHistory.unshift({
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
|
||||
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
|
||||
}
|
||||
state.handNumber += 1;
|
||||
},
|
||||
|
||||
clearHistory(state) {
|
||||
state.handHistory = [];
|
||||
state.handNumber = 1;
|
||||
},
|
||||
|
||||
// Running per-player totals for the whole session, so a dealer settling up at the
|
||||
// end of the night reads one number per person instead of adding up every hand.
|
||||
// Keyed by seat id, which survives renames and reseating; hands recorded before
|
||||
// the ledger existed simply don't contribute to the totals.
|
||||
sessionStandings(state) {
|
||||
const totals = new Map();
|
||||
|
||||
(state.handHistory || []).forEach(entry => {
|
||||
(entry.ledger || []).forEach(row => {
|
||||
// History is newest-first, so the first name seen for an id is the
|
||||
// most recent one the dealer typed.
|
||||
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
|
||||
running.in += row.in;
|
||||
running.won += row.won;
|
||||
totals.set(row.id, running);
|
||||
});
|
||||
});
|
||||
|
||||
// Cash bought in and chips currently in front don't come from the hand log at
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
|
||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
// left a player who bought in and then sat through a few hands folding —
|
||||
// or who just sat down — missing entirely, so the banker's own record didn't
|
||||
// mention the cash they'd taken. They turn up at cash-out holding chips the
|
||||
// list never accounted for.
|
||||
state.seats.forEach((player, index) => {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
|
||||
});
|
||||
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||
// Chips already pushed into the hand in progress are still this
|
||||
// player's money until it's won. Leaving them out makes the cash
|
||||
// position disagree with the hand log for as long as a hand is open —
|
||||
// and blinds are posted the instant the previous one settles, so that
|
||||
// window is almost always.
|
||||
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
||||
|
||||
return {
|
||||
...t,
|
||||
in: Money.round(t.in),
|
||||
won: Money.round(t.won),
|
||||
net: Money.round(t.won - t.in),
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.net - a.net);
|
||||
},
|
||||
|
||||
// Two players change chairs.
|
||||
//
|
||||
// The whole seat moves — name, stack, cards, what they have in the pot — because
|
||||
// that is what changing chairs is. What stays behind is everything that belongs to
|
||||
// the *position*: the button, the blinds and the acting index all name a chair, and
|
||||
// a player who moves into the big blind's chair is the big blind. That is the same
|
||||
// answer a card room gives.
|
||||
swapSeats(state, a, b) {
|
||||
const seats = state.seats || [];
|
||||
if (a === b) return false;
|
||||
if (!seats[a] || !seats[b]) return false;
|
||||
[seats[a], seats[b]] = [seats[b], seats[a]];
|
||||
return true;
|
||||
},
|
||||
|
||||
// The one check a banker actually needs, and the one nothing here did.
|
||||
//
|
||||
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
||||
// play add up to more than the cash that came in, that promise can't be kept —
|
||||
// and the way you find out is the last two players cashing out and the box being
|
||||
// empty. It's always someone taking chips without paying, a rebuy that never got
|
||||
// recorded, or a miscount, and it's silent until it's too late to work out which.
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const difference = Money.round(chipsInPlay - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HoldemState;
|
||||
}
|
||||
@@ -103,9 +103,13 @@ CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
|
||||
/* Record one played position (post-move FEN) into the accumulator. */
|
||||
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
|
||||
|
||||
/* Apply a finished game's outcome to the global weights and save: rewards the winner's
|
||||
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
|
||||
* material-imbalance draw). winner: 0 = white, 1 = black. */
|
||||
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
|
||||
* on a square, or a feature) tracks a presence-weighted running average of the game's
|
||||
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
|
||||
* draw) minus how much of that outcome the winner's average material edge already explains —
|
||||
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
|
||||
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
|
||||
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
|
||||
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
@@ -115,11 +116,25 @@ struct ChessEngine {
|
||||
/* The process-global learned weights: the single source of truth, loaded from disk once and
|
||||
* updated in place by training. Engine handles snapshot it at creation; the visualization
|
||||
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
|
||||
* per-handle copy, so it never touches this concurrently). */
|
||||
* per-handle copy, so it never touches this concurrently).
|
||||
*
|
||||
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
|
||||
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
|
||||
* the presence-weighted count of games that touched the cell (how much to trust the average);
|
||||
* score is the presence-weighted sum of each game's material-adjusted advantage (see
|
||||
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
|
||||
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
|
||||
struct LearnedWeights {
|
||||
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int featW[FEATURE_NB] = {};
|
||||
|
||||
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||
double mgScore [chess::PIECE_TYPE_NB][64] = {};
|
||||
double egVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||
double egScore [chess::PIECE_TYPE_NB][64] = {};
|
||||
double featVisits[FEATURE_NB] = {};
|
||||
double featScore [FEATURE_NB] = {};
|
||||
};
|
||||
|
||||
static LearnedWeights g_weights;
|
||||
@@ -127,13 +142,15 @@ static std::mutex g_weightsMutex;
|
||||
static std::string g_weightsPath;
|
||||
|
||||
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
|
||||
* each side's pieces sat (split into midgame/endgame by phase) and each side's feature
|
||||
* activations; trainer_apply turns the totals into weight nudges. Squares are white-relative
|
||||
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
|
||||
* activations, and the white-minus-black material balance; trainer_apply turns the totals
|
||||
* into a presence-weighted update to the running per-cell averages. Squares are white-relative
|
||||
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
|
||||
struct Trainer {
|
||||
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
|
||||
int plies = 0;
|
||||
};
|
||||
|
||||
@@ -172,9 +189,12 @@ static int parse_variant(const char* options) {
|
||||
}
|
||||
|
||||
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
|
||||
* FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the
|
||||
* rest neutral (0), so an absent weights file just means "train from a blank slate".
|
||||
* Caller holds g_weightsMutex. */
|
||||
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
|
||||
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
|
||||
* missing file or short read leaves the rest neutral (0), so an absent weights file just means
|
||||
* "train from a blank slate" — and a file written by the pre-fractional-learning format (which
|
||||
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
|
||||
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
|
||||
static void load_global_weights(const char* path) {
|
||||
g_weights = LearnedWeights{}; /* reset to neutral before loading */
|
||||
|
||||
@@ -190,9 +210,29 @@ static void load_global_weights(const char* path) {
|
||||
if (!(f >> g_weights.eg[pt][sq])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featW[i])) return;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.mgVisits[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.mgScore[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.egVisits[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.egScore[pt][sq])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featVisits[i])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featScore[i])) return;
|
||||
}
|
||||
|
||||
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
|
||||
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
|
||||
* precision on the doubles (visits/score are running sums that keep accumulating across many
|
||||
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
|
||||
* lock. */
|
||||
static void save_global_weights() {
|
||||
if (g_weightsPath.empty()) return;
|
||||
std::ofstream f(g_weightsPath);
|
||||
@@ -203,6 +243,18 @@ static void save_global_weights() {
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
|
||||
f << std::setprecision(17);
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
}
|
||||
|
||||
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
|
||||
@@ -382,13 +434,18 @@ static int evaluate(const chess::Position& pos) {
|
||||
* compute_features() is the single source of feature activations, used by BOTH the eval here
|
||||
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
|
||||
|
||||
/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a
|
||||
* square, summed); features accumulate normalized per-ply activation (averaged, divided by a
|
||||
* nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */
|
||||
static constexpr double SQUARE_LR = 0.5;
|
||||
static constexpr int SQ_CLAMP = 250;
|
||||
static constexpr double FEAT_LR = 2.0;
|
||||
static constexpr int FEAT_CLAMP = 500;
|
||||
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
|
||||
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
|
||||
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
|
||||
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
|
||||
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
|
||||
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
|
||||
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
|
||||
* explains, so training credits placement/features only for the part of the result that
|
||||
* material didn't already predict. */
|
||||
static constexpr int SQ_OUT_SCALE = 250;
|
||||
static constexpr int FEAT_OUT_SCALE = 500;
|
||||
static constexpr double MATERIAL_ELO_SCALE = 400.0;
|
||||
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
|
||||
|
||||
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
|
||||
@@ -823,7 +880,9 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||
chess::Position pos = chess::Position::from_fen(fen);
|
||||
double phase = game_phase(pos);
|
||||
|
||||
/* Per-square occupancy, split into midgame/endgame by phase, white-relative. */
|
||||
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
|
||||
* white-minus-black material balance at this ply, used at trainer_apply time to strip
|
||||
* "material already explains this" out of the training signal. */
|
||||
chess::Bitboard occ = pos.pieces();
|
||||
while (occ) {
|
||||
chess::Square s = chess::pop_lsb(occ);
|
||||
@@ -833,6 +892,7 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
|
||||
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
||||
t->egOcc[c][pt][relSq] += phase;
|
||||
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
|
||||
}
|
||||
|
||||
/* Per-side feature activations. */
|
||||
@@ -847,34 +907,66 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||
t->plies++;
|
||||
}
|
||||
|
||||
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
|
||||
* of an unbounded incremental nudge:
|
||||
*
|
||||
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
|
||||
* on this square, minus fraction lost"
|
||||
*
|
||||
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
|
||||
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
|
||||
* result ISN'T already explained by material: `expected` is the win probability the winner's
|
||||
* average material edge over the game implies (the same centipawn->win-probability curve
|
||||
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
|
||||
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
|
||||
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
|
||||
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
|
||||
* held) while materially behind is highly informative and gets a correspondingly large update.
|
||||
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
|
||||
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
|
||||
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
|
||||
* expected`, just negative. */
|
||||
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
|
||||
if (!t) return;
|
||||
if (!t || t->plies <= 0) return;
|
||||
|
||||
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
|
||||
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
|
||||
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
|
||||
double advantageMag = weight * (1.0 - expected);
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||
|
||||
/* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */
|
||||
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
|
||||
for (int pass = 0; pass < 2; ++pass) {
|
||||
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
|
||||
int sign = pass == 0 ? 1 : -1;
|
||||
double advantage = pass == 0 ? advantageMag : -advantageMag;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) {
|
||||
if (t->mgOcc[side][pt][sq] != 0.0) {
|
||||
int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight));
|
||||
g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
||||
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
|
||||
if (mgFrac != 0.0) {
|
||||
g_weights.mgVisits[pt][sq] += mgFrac;
|
||||
g_weights.mgScore [pt][sq] += mgFrac * advantage;
|
||||
g_weights.mg[pt][sq] = int(std::lround(
|
||||
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
|
||||
}
|
||||
if (t->egOcc[side][pt][sq] != 0.0) {
|
||||
int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight));
|
||||
g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
||||
double egFrac = t->egOcc[side][pt][sq] / t->plies;
|
||||
if (egFrac != 0.0) {
|
||||
g_weights.egVisits[pt][sq] += egFrac;
|
||||
g_weights.egScore [pt][sq] += egFrac * advantage;
|
||||
g_weights.eg[pt][sq] = int(std::lround(
|
||||
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
|
||||
}
|
||||
}
|
||||
|
||||
if (t->plies > 0)
|
||||
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||
double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */
|
||||
int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight));
|
||||
g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP);
|
||||
}
|
||||
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
|
||||
if (activation == 0.0) continue;
|
||||
g_weights.featVisits[i] += std::abs(activation);
|
||||
g_weights.featScore [i] += activation * advantage;
|
||||
g_weights.featW[i] = int(std::lround(
|
||||
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
|
||||
}
|
||||
}
|
||||
|
||||
save_global_weights();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Hold'em dealer tests
|
||||
|
||||
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
|
||||
side-pot calculator, the split calculator and the table state.
|
||||
|
||||
```bash
|
||||
node --test tests/holdem/*.test.js
|
||||
```
|
||||
|
||||
No install step, no `package.json`, no dependencies. The browser modules are plain
|
||||
script-tag files that each end with a `module.exports` guard, so Node can require them
|
||||
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
|
||||
other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
|
||||
## What's covered
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
|
||||
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||||
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
|
||||
The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
|
||||
in a browser: whether a dealer can actually reach every control.
|
||||
|
||||
```bash
|
||||
node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
```
|
||||
|
||||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||
phones on their side.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
The contrast check is built so it cannot report a false positive: backgrounds
|
||||
composite through alpha, and where a gradient is involved every colour stop is tried
|
||||
and the most flattering one used. A finding means the text fails even under the
|
||||
reading most generous to it. Two earlier attempts at this got it wrong in both
|
||||
directions — one read only `background-color` and scored text on a gradient against
|
||||
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
|
||||
against known-answer cases before it is believed.
|
||||
|
||||
Touch targets are measured by probing outward with `elementFromPoint` rather than by
|
||||
reading the element box, because the box lies in both directions: a small control can
|
||||
be padded out with a transparent `::after` and respond well beyond its painted size,
|
||||
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
|
||||
enforced where `hover: none` says there's no mouse.
|
||||
|
||||
It exists because these checks were being re-typed into a console every time
|
||||
something changed, and each time they only covered whatever had just been touched.
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Why the randomised suite exists
|
||||
|
||||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||
to empty chairs, the button skipping past players, heads-up acting out of order, an
|
||||
overbet announced as a side pot. None of them show up on a full six-handed table with
|
||||
equal stacks, which is the table anyone writes a fixed example for.
|
||||
|
||||
`invariants.test.js` deals a thousand hands across random seat counts, random empty
|
||||
chairs, random stack depths and random dealer input, and checks the things a dealer
|
||||
would notice being wrong:
|
||||
|
||||
- chips are never created or destroyed during play
|
||||
- the pot breakdown plus the uncalled returns always equal the money that went in
|
||||
- paying the pot out settles it to the cent
|
||||
- once uncalled chips are handed back, nobody is still betting above the whole table
|
||||
- the button and both blinds always land on players who are actually in the hand
|
||||
|
||||
The generator is seeded, so a failure reproduces from the seed printed in the message
|
||||
rather than being a flake to re-run.
|
||||
@@ -0,0 +1,98 @@
|
||||
// Who the felt marks "ALL IN".
|
||||
//
|
||||
// The mark's whole value is that a dealer can scan the ring and see who can still be
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
|
||||
assert.equal(state.seats[3].stack, 0);
|
||||
});
|
||||
|
||||
test("a player with chips behind is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("a busted player who was dealt in but has put nothing in is not", () => {
|
||||
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
|
||||
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a folded player with an empty stack is not", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].committed = 5;
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[1].folded = true;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("an empty chair is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
|
||||
});
|
||||
|
||||
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("the seats marked are exactly the ones the action passes over", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[1].stack = 8;
|
||||
state.seats[3].stack = 30;
|
||||
Betting.beginHand(state);
|
||||
|
||||
// Everyone in, two of them for less than the others can cover.
|
||||
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
const marked = state.seats
|
||||
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
|
||||
.filter(index => index !== null);
|
||||
const skipped = state.seats
|
||||
.map((player, index) =>
|
||||
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
|
||||
.filter(index => index !== null);
|
||||
|
||||
assert.deepEqual(marked, skipped);
|
||||
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
|
||||
});
|
||||
|
||||
test("nobody is marked once the next hand starts", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 5;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
|
||||
|
||||
// The short stack got there by winning nothing; give them chips back the way a
|
||||
// dealer would before the next deal.
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[2].stack = 50;
|
||||
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
|
||||
});
|
||||
|
||||
test("marking nobody doesn't move a chip", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
const before = chipsOnTable(state);
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// The banker's job, which is a different job from the dealer's: every chip on the
|
||||
// table is a promise to pay cash for it later, and somebody has to be able to check
|
||||
// that the promise can be kept before the last two players try to cash out.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, Money, makeTable } = require("./load");
|
||||
|
||||
const seat = (state, index, name, cash, chips) => {
|
||||
const p = state.seats[index];
|
||||
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
|
||||
return p;
|
||||
};
|
||||
|
||||
describe("everyone with money in the game is accounted for", () => {
|
||||
test("a player who has bought in but played no logged hand still appears", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
seat(state, 2, "Cal", 200, 200); // just sat down
|
||||
|
||||
state.seats[0].amountOverride = 10;
|
||||
state.seats[1].amountOverride = 10;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
|
||||
state.seats[0].amountOverride = null;
|
||||
state.seats[1].amountOverride = null;
|
||||
|
||||
const names = HoldemState.sessionStandings(state).map(r => r.name);
|
||||
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
test("their row reads as no hands played, not as a loss", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 200, 200);
|
||||
|
||||
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
|
||||
assert.equal(cal.in, 0);
|
||||
assert.equal(cal.won, 0);
|
||||
assert.equal(cal.net, 0);
|
||||
assert.equal(cal.boughtIn, 200);
|
||||
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
|
||||
});
|
||||
|
||||
test("an empty chair is not dragged into the settle-up", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test("somebody who has cashed out completely still shows what they took", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 0); // handed every chip back
|
||||
seat(state, 1, "Ben", 100, 200);
|
||||
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
|
||||
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("does the cash box cover the chips on the table", () => {
|
||||
test("a straight game balances", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 60);
|
||||
seat(state, 1, "Ben", 100, 140);
|
||||
seat(state, 2, "Cal", 100, 100);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 300);
|
||||
assert.equal(bank.chipsInPlay, 300);
|
||||
assert.equal(bank.difference, 0);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("chips outrunning the cash box is caught before cash-out", () => {
|
||||
// The 2am failure: the last players out can't be paid.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 300);
|
||||
assert.equal(bank.chipsInPlay, 460);
|
||||
assert.equal(bank.difference, 160);
|
||||
assert.equal(bank.balanced, false);
|
||||
});
|
||||
|
||||
test("cash taken for chips never handed over is caught too", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.difference, -100);
|
||||
assert.equal(bank.balanced, false);
|
||||
});
|
||||
|
||||
test("chips in the middle still count as chips in play", () => {
|
||||
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
|
||||
// would report a shortfall every time a hand was open — which is nearly always.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||
|
||||
const before = HoldemState.bankReconciliation(state);
|
||||
assert.equal(before.balanced, true);
|
||||
|
||||
Betting.beginHand(state); // blinds go in
|
||||
Betting.raiseTo(state, 0, 25); // and a raise
|
||||
Betting.sweepToHandTotals(state); // banked into the pot
|
||||
|
||||
const during = HoldemState.bankReconciliation(state);
|
||||
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
|
||||
assert.equal(during.chipsInPlay, before.chipsInPlay);
|
||||
});
|
||||
|
||||
test("a whole session of play never knocks the books out", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||
|
||||
for (let hand = 0; hand < 6; hand++) {
|
||||
Betting.beginHand(state);
|
||||
Betting.liveSeats(state).forEach(s => {
|
||||
if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
|
||||
});
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const winner = Betting.liveSeats(state)[0];
|
||||
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
|
||||
// A proper rebuy: cash to the banker, chips to the player.
|
||||
state.seats[0].buyIns += 100;
|
||||
state.seats[0].stack += 100;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
// Chips taken without paying.
|
||||
state.seats[1].stack += 50;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, 50);
|
||||
});
|
||||
|
||||
test("nothing to reconcile before anybody has bought in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 0);
|
||||
assert.equal(bank.chipsInPlay, 0);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("float dust is not a discrepancy", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
|
||||
seat(state, 1, "Ben", 10, 10);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("Betting.beginHand", () => {
|
||||
test("blinds are posted and the seat after the big blind acts first", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[1].committed, 1, "small blind");
|
||||
assert.equal(state.seats[2].committed, 2, "big blind");
|
||||
assert.equal(state.currentBet, 2);
|
||||
assert.equal(state.actingSeatIndex, 3, "under the gun");
|
||||
});
|
||||
|
||||
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
|
||||
assert.equal(state.seats[5].committed, 2);
|
||||
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
|
||||
});
|
||||
|
||||
test("heads-up, the big blind acts first once the flop is out", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 2);
|
||||
Betting.check(state, 5);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "flop");
|
||||
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
|
||||
});
|
||||
|
||||
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
|
||||
state.seats[0].stack = 10; // can't cover the small blind
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
|
||||
assert.equal(state.seats[0].committed, 10);
|
||||
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
|
||||
});
|
||||
|
||||
test("posting blinds never invents or destroys chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
const before = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Betting street completion", () => {
|
||||
test("a street ends once everyone has matched the bet and had a turn", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
|
||||
|
||||
Betting.check(state, 2);
|
||||
assert.equal(Betting.isStreetComplete(state), true);
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("a raise reopens the action for everyone who had already called", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(state.seats[3].hasActed, true);
|
||||
|
||||
Betting.raiseTo(state, 1, 10);
|
||||
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
|
||||
assert.equal(state.seats[0].hasActed, false);
|
||||
assert.equal(state.currentBet, 10);
|
||||
assert.equal(Betting.isStreetComplete(state), false);
|
||||
});
|
||||
|
||||
test("the action skips a player who is all-in but still in the hand", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.seats[3].stack = 5;
|
||||
Betting.beginHand(state);
|
||||
|
||||
Betting.allIn(state, 3);
|
||||
assert.equal(state.seats[3].stack, 0);
|
||||
assert.notEqual(state.actingSeatIndex, 3);
|
||||
// They are still live and can still win the pot.
|
||||
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
|
||||
});
|
||||
|
||||
test("closing a street sweeps chips into hand totals and resets the bet", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
Betting.check(state, 2);
|
||||
|
||||
const before = chipsOnTable(state);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.currentBet, 0);
|
||||
assert.equal(state.street, "flop");
|
||||
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
|
||||
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||
assert.equal(Betting.handTotal(state.seats[2]), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Betting all-in run-outs", () => {
|
||||
test("with everyone all-in there is nobody left to ask", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, 2);
|
||||
Betting.allIn(state, 0);
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false);
|
||||
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
|
||||
|
||||
Betting.closeStreet(state);
|
||||
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
|
||||
});
|
||||
|
||||
test("one player with chips against an all-in cannot bet alone", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 5;
|
||||
Betting.beginHand(state);
|
||||
Betting.fold(state, 2);
|
||||
Betting.allIn(state, 0);
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false);
|
||||
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
|
||||
});
|
||||
|
||||
test("everyone folding to one player ends the hand", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.fold(state, 3);
|
||||
Betting.fold(state, 0);
|
||||
Betting.fold(state, 1);
|
||||
|
||||
assert.equal(Betting.isHandOver(state), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Betting money handling", () => {
|
||||
test("a player can never put in more than they have", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const moved = Betting.putIn(state, 0, 500);
|
||||
assert.equal(moved, 30);
|
||||
assert.equal(state.seats[0].stack, 0);
|
||||
});
|
||||
|
||||
test("raise-to is what a dealer says out loud, not raise-by", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
|
||||
assert.equal(state.seats[3].stack, 90);
|
||||
});
|
||||
|
||||
test("the minimum raise has to match the last one", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
|
||||
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
|
||||
});
|
||||
|
||||
test("winnings land back in front of the player", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
|
||||
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
|
||||
});
|
||||
|
||||
test("fractional blinds don't drift into floating-point dust", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); // button limps
|
||||
Betting.call(state, 1); // small blind completes
|
||||
Betting.check(state, 2); // big blind is happy
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
|
||||
assert.equal(Betting.potSoFar(state), 0.6);
|
||||
state.seats.forEach(p => assert.equal(p.stack, 9.8));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Dealing around a player who busted and hasn't bought back in.
|
||||
//
|
||||
// A home game produces one of these about once an hour. The table used to deal them
|
||||
// in anyway, promise a blind from a seat with nothing to post it with, take it from
|
||||
// nobody, and then sit on a hand that could never finish.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const postedBy = state =>
|
||||
state.seats
|
||||
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
|
||||
.filter(Boolean);
|
||||
|
||||
test("a seat with no chips and nothing in the pot is broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.deepEqual(Betting.brokeSeats(state), [2]);
|
||||
});
|
||||
|
||||
test("an all-in player is not broke — they have money riding on the hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, 2);
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a chair nobody is sitting in isn't broke, it's empty", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].name = "Cal";
|
||||
state.seats[2].buyIns = 100;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
|
||||
assert.equal(state.seats[2].name, "Cal");
|
||||
assert.equal(state.seats[2].buyIns, 100);
|
||||
});
|
||||
|
||||
test("the blinds skip the broke seat and land on players who can post", () => {
|
||||
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.deepEqual(postedBy(state), [
|
||||
{ seat: 3, amount: state.smallBlind },
|
||||
{ seat: 4, amount: state.bigBlind },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the preview promises what the deal produces", () => {
|
||||
// The fault this whole step exists to prevent: the preview once read a table the
|
||||
// deal was about to change underneath it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.deepEqual(preview.satOut, [1]);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const posted = postedBy(state);
|
||||
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
|
||||
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
|
||||
});
|
||||
|
||||
test("asking for the preview doesn't sit anybody out on its own", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.blindPreview(state);
|
||||
|
||||
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
|
||||
});
|
||||
|
||||
test("with only one player left holding chips there is nothing to preview", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
|
||||
assert.equal(Betting.blindPreview(state), null);
|
||||
});
|
||||
|
||||
test("no chips move when the broke seats are dealt around", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("the board isn't running out before the hand is dealt", () => {
|
||||
// Two busted players and one with chips looks exactly like a table of all-ins to
|
||||
// the run-out test, and the bar was announcing one over an empty felt.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
assert.equal(Betting.isRunningOut(state), false);
|
||||
});
|
||||
|
||||
test("buying back in puts them in the next hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
|
||||
// What the dealer does when they hand over cash: chips, and back in.
|
||||
state.seats[2].stack = 100;
|
||||
state.seats[2].inHand = true;
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
});
|
||||
|
||||
test("the table dealt around them counts the right number of players", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 0;
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 4);
|
||||
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
|
||||
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Whether handing a seat chips also puts them back in the game.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
test("a busted player who has been sat out comes back in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
Betting.beginHand(state); // the deal sits them out
|
||||
|
||||
assert.equal(state.seats[1].inHand, false);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
|
||||
});
|
||||
|
||||
test("a chair being bought into for the first time comes in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("a seat sat out while holding chips stays out", () => {
|
||||
// A dealer sat them out on purpose and is topping them up. Dealing them back in
|
||||
// would be the tool guessing at something it wasn't told.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].inHand = false;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("somebody already in the hand is left exactly where they are", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
|
||||
|
||||
// Including a player who is in but out of chips — they're all-in, not sat out.
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a blank stack field reads as nothing recorded, not as a number", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[3].name = "Dee";
|
||||
state.seats[3].stack = "";
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
|
||||
});
|
||||
|
||||
test("it doesn't ask the table what a missing stack would have been", () => {
|
||||
// stackOf substitutes the table's starting stack for a seat that has none, which
|
||||
// would make a seat with nothing recorded look like it was holding chips.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.defaultStack = 500;
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].inHand = false;
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
Betting.buyInDealsThemIn(state.seats[2]);
|
||||
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("nothing to decide about a seat that isn't there", () => {
|
||||
assert.equal(Betting.buyInDealsThemIn(null), false);
|
||||
assert.equal(Betting.buyInDealsThemIn(undefined), false);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// The chips to actually push, for an amount.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
// A standard house set.
|
||||
const LEGEND = [
|
||||
{ value: 1, color: "#fff" },
|
||||
{ value: 5, color: "#c0392b" },
|
||||
{ value: 25, color: "#27ae60" },
|
||||
{ value: 100, color: "#2c3e50" },
|
||||
{ value: 500, color: "#8e44ad" },
|
||||
];
|
||||
|
||||
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
|
||||
|
||||
test("a payout comes back as chips, largest first", () => {
|
||||
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
|
||||
});
|
||||
|
||||
test("it uses the fewest chips on a normal set", () => {
|
||||
const { chips } = Money.chipBreakdown(500, LEGEND);
|
||||
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
|
||||
});
|
||||
|
||||
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
|
||||
assert.equal(say(7), "1×$5, 2×$1");
|
||||
});
|
||||
|
||||
test("nothing to pay is nothing to say", () => {
|
||||
assert.equal(say(0), "");
|
||||
assert.equal(say(-5), "");
|
||||
});
|
||||
|
||||
test("no legend means no advice, not a wrong answer", () => {
|
||||
// A table that never set chip colours up gets the amount on its own.
|
||||
assert.equal(say(132, []), "");
|
||||
assert.equal(say(132, null), "");
|
||||
});
|
||||
|
||||
test("a legend with no values in it yet is the same as none", () => {
|
||||
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
|
||||
});
|
||||
|
||||
test("what the smallest chip can't make is reported, not rounded away", () => {
|
||||
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
|
||||
// and they can only solve it if they're told.
|
||||
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
|
||||
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
|
||||
assert.equal(remainder, 0.5);
|
||||
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
|
||||
});
|
||||
|
||||
test("cent chips make cent amounts exactly", () => {
|
||||
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
|
||||
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
|
||||
});
|
||||
|
||||
test("the chips add up to the amount", () => {
|
||||
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
|
||||
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
|
||||
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
|
||||
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a legend in any order still pays largest first", () => {
|
||||
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
|
||||
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
|
||||
});
|
||||
|
||||
test("the colour comes along, so the felt can say it in chips", () => {
|
||||
const { chips } = Money.chipBreakdown(25, LEGEND);
|
||||
assert.equal(chips[0].color, "#27ae60");
|
||||
});
|
||||
|
||||
test("a legend that exists but can't reach the amount does say so", () => {
|
||||
// Different from having no legend at all: this table has told us its chips, and
|
||||
// the answer is that they don't go that low.
|
||||
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// Dealing and betting are two decisions, and fusing them meant the table guessed.
|
||||
//
|
||||
// The moment between hands is when people sit down, stand up and argue about whose
|
||||
// turn it is. With blinds posted the instant the previous hand ended, the blind
|
||||
// badges recomputed off `inHand` while the committed chips stayed frozen — so seating
|
||||
// somebody produced a big blind who had posted nothing.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const postedBy = state => state.seats
|
||||
.map((p, i) => (Betting.committed(p) > 0 ? i : null))
|
||||
.filter(i => i !== null);
|
||||
|
||||
describe("the table waits to be dealt", () => {
|
||||
test("finishing a hand leaves the next one un-dealt", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.bettingOpen, true);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is on the clock");
|
||||
assert.deepEqual(postedBy(state), [], "and no blind has been committed");
|
||||
});
|
||||
|
||||
test("no money moves until the dealer deals", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
assert.equal(HoldemState.totalPot(state), 0);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.bettingOpen, true);
|
||||
assert.equal(postedBy(state).length, 2, "now the blinds are in");
|
||||
assert.equal(chipsOnTable(state), start, "and nothing was created doing it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setting the table costs nothing", () => {
|
||||
test("seating a player before the deal changes who posts", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const before = Betting.blindPreview(state);
|
||||
state.seats[3].inHand = true;
|
||||
state.seats[3].stack = 100;
|
||||
const after = Betting.blindPreview(state);
|
||||
|
||||
assert.notDeepEqual(
|
||||
[before.sbIndex, before.bbIndex],
|
||||
[after.sbIndex, after.bbIndex],
|
||||
"the preview follows the new table"
|
||||
);
|
||||
assert.deepEqual(postedBy(state), [], "and still nothing is committed");
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(),
|
||||
"the money lands where the preview said it would");
|
||||
});
|
||||
|
||||
test("removing a player before the deal can't strand their blind", () => {
|
||||
// The old failure: take out the big blind after the hand had begun and their
|
||||
// chips stayed in the pot while the badge moved to somebody who'd posted
|
||||
// nothing. Before the deal there is nothing to strand.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const doomed = Betting.blindPreview(state).bbIndex;
|
||||
state.seats[doomed].inHand = false;
|
||||
|
||||
assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded");
|
||||
|
||||
Betting.beginHand(state);
|
||||
const posted = postedBy(state);
|
||||
assert.ok(!posted.includes(doomed), "the player who left posts nothing");
|
||||
posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post"));
|
||||
});
|
||||
|
||||
test("moving the button before the deal just moves it", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
[2, 4, 6, 0].forEach(seat => {
|
||||
state.dealerSeatIndex = seat;
|
||||
assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`);
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
});
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air");
|
||||
});
|
||||
|
||||
test("what the preview promises is what gets posted", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||
});
|
||||
|
||||
test("there's nothing to preview without two players", () => {
|
||||
const alone = makeTable({ seatCount: 6, seated: [2] });
|
||||
HoldemState.startNextHand(alone);
|
||||
assert.equal(Betting.blindPreview(alone), null);
|
||||
|
||||
const empty = makeTable({ seatCount: 6, seated: [] });
|
||||
HoldemState.startNextHand(empty);
|
||||
assert.equal(Betting.blindPreview(empty), null);
|
||||
});
|
||||
|
||||
test("a hand-picked blind shows up in the preview", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.manual, true);
|
||||
assert.equal(preview.sbIndex, 3);
|
||||
assert.equal(preview.bbIndex, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table saved mid-hand comes back mid-hand", () => {
|
||||
const loadWith = saved => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} };
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
return state;
|
||||
};
|
||||
|
||||
test("chips already committed mean the betting was open", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }],
|
||||
handNumber: 3,
|
||||
});
|
||||
assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand");
|
||||
});
|
||||
|
||||
test("somebody on the clock means the betting was open", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||
actingSeatIndex: 1, handNumber: 2,
|
||||
});
|
||||
assert.equal(state.bettingOpen, true);
|
||||
});
|
||||
|
||||
test("a quiet table comes back waiting to be dealt", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||
actingSeatIndex: null, handNumber: 5,
|
||||
});
|
||||
assert.equal(state.bettingOpen, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the preview tells the truth about the button", () => {
|
||||
test("a button parked on an empty chair is answered for where it will be", () => {
|
||||
// The preview used to read the button where it sat, while dealing relocated it
|
||||
// first — so it promised one pair of blinds and produced another.
|
||||
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
assert.equal(state.seats[0].inHand, false, "the button is on an empty chair");
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.buttonMoves, true, "and the preview knows it will move");
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||
assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said");
|
||||
});
|
||||
|
||||
test("asking where the button will be doesn't move it", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
|
||||
Betting.blindPreview(state);
|
||||
Betting.blindPreview(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action");
|
||||
});
|
||||
|
||||
test("a button already on a player is left alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.buttonMoves, false);
|
||||
assert.equal(preview.buttonIndex, 0);
|
||||
});
|
||||
|
||||
test("buttonSeatForDeal copes with a table nobody is sitting at", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [] });
|
||||
assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||
// noticing every way the log can actually change — a stale panel would have the
|
||||
// dealer reading last hand's payouts while settling this one.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// historySignature() reads Render.state, so point it at a table for each case.
|
||||
const signatureOf = state => {
|
||||
Render.state = state;
|
||||
return Render.historySignature();
|
||||
};
|
||||
|
||||
const tableWithHands = (count) => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
for (let i = 0; i < count; i++) {
|
||||
state.seats[0].amountOverride = 2;
|
||||
state.seats[1].amountOverride = 2;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the history panel knows when to redraw", () => {
|
||||
test("nothing changes while a hand is being played", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
|
||||
// A whole street of betting touches no part of the log.
|
||||
state.seats[0].committed = 5;
|
||||
state.seats[1].folded = true;
|
||||
state.actingSeatIndex = 2;
|
||||
state.street = "flop";
|
||||
|
||||
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||
});
|
||||
|
||||
test("settling a hand changes it", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("clearing the history changes it", () => {
|
||||
const state = tableWithHands(5);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.clearHistory(state);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("undoing back to a shorter log changes it", () => {
|
||||
const state = tableWithHands(2);
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||
const afterRecord = signatureOf(state);
|
||||
|
||||
// The undo path swaps the state's contents in place.
|
||||
Object.keys(state).forEach(k => delete state[k]);
|
||||
Object.assign(state, snapshot);
|
||||
|
||||
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||
});
|
||||
|
||||
test("two hands that differ only in payout are told apart", () => {
|
||||
const a = tableWithHands(1);
|
||||
const b = tableWithHands(1);
|
||||
// Same count and hand number; different pot.
|
||||
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
|
||||
test("the hand counter moving on changes it", () => {
|
||||
// The counter badge is drawn by the same function, so a new hand number has
|
||||
// to invalidate even when the log itself is untouched.
|
||||
const state = tableWithHands(2);
|
||||
const before = signatureOf(state);
|
||||
state.handNumber += 1;
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("an empty log has a stable signature of its own", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||
const first = signatureOf(state);
|
||||
assert.equal(signatureOf(state), first);
|
||||
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand-time cache", () => {
|
||||
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||
const first = Render.formatHandTime(stamp);
|
||||
assert.equal(Render.handTimeCache.size, 1);
|
||||
|
||||
const second = Render.formatHandTime(stamp);
|
||||
assert.equal(second, first);
|
||||
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||
});
|
||||
|
||||
test("different timestamps still format differently", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(Render.handTimeCache.size, 2);
|
||||
});
|
||||
|
||||
test("a nonsense timestamp doesn't throw", () => {
|
||||
Render.handTimeCache.clear();
|
||||
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// Randomised whole-hand play against the invariants a dealer would notice being broken.
|
||||
//
|
||||
// These ran once, by hand, in a browser console while the betting engine was being
|
||||
// fixed, and then went away with the tab. The bugs they caught (blinds posted to empty
|
||||
// chairs, the button skipping players, heads-up acting out of order) are all cases you
|
||||
// only hit on a particular table shape, so a fixed set of examples misses them. Keeping
|
||||
// the generator means the next change gets checked against the same thousand tables.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, PotCalculator, HoldemState, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||
|
||||
// Deterministic PRNG: a failure has to be reproducible from the seed printed with it,
|
||||
// otherwise a randomised suite is just a flaky one.
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// Plays one hand to completion with random but legal-ish dealer input.
|
||||
function playHand(random) {
|
||||
const seatCount = 2 + Math.floor(random() * 9);
|
||||
const seatIndexes = [...Array(seatCount).keys()];
|
||||
const seated = seatIndexes.filter(() => random() < 0.7);
|
||||
if (seated.length < 2) seated.push(...seatIndexes.filter(i => !seated.includes(i)).slice(0, 2 - seated.length));
|
||||
|
||||
const bigBlind = [2, 5, 0.5][Math.floor(random() * 3)];
|
||||
const state = makeTable({
|
||||
seatCount,
|
||||
seated,
|
||||
stack: 0,
|
||||
smallBlind: bigBlind / 2,
|
||||
bigBlind,
|
||||
dealerSeatIndex: Math.floor(random() * seatCount),
|
||||
});
|
||||
|
||||
// Varied stack depths, so short stacks and side pots actually happen.
|
||||
seated.forEach(i => {
|
||||
state.seats[i].stack = Money.round(bigBlind * (1 + Math.floor(random() * 60)));
|
||||
});
|
||||
|
||||
const startingChips = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
let guard = 0;
|
||||
while (!Betting.isHandOver(state) && state.street !== "done" && guard++ < 400) {
|
||||
if (state.actingSeatIndex === null) {
|
||||
if (state.street === "river" || !Betting.canStillBet(state)) break;
|
||||
Betting.closeStreet(state);
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = state.actingSeatIndex;
|
||||
const player = state.seats[index];
|
||||
const toCall = Betting.toCall(state, player);
|
||||
const roll = random();
|
||||
|
||||
if (roll < 0.15) Betting.fold(state, index);
|
||||
else if (roll < 0.25) Betting.allIn(state, index);
|
||||
else if (toCall > 0) Betting.call(state, index);
|
||||
else if (roll < 0.75) Betting.check(state, index);
|
||||
else Betting.raiseTo(state, index, Betting.minRaiseTo(state));
|
||||
}
|
||||
|
||||
Betting.sweepToHandTotals(state);
|
||||
return { state, startingChips, seated };
|
||||
}
|
||||
|
||||
describe("randomised hands hold the dealer's invariants", () => {
|
||||
const HANDS = 1000;
|
||||
|
||||
test(`${HANDS} hands never create or destroy a chip`, () => {
|
||||
const random = rng(20260901);
|
||||
for (let hand = 0; hand < HANDS; hand++) {
|
||||
const { state, startingChips } = playHand(random);
|
||||
assert.equal(
|
||||
chipsOnTable(state),
|
||||
startingChips,
|
||||
`hand ${hand}: chips on the table changed during play`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${HANDS} hands: pots plus uncalled returns always equal the money in`, () => {
|
||||
const random = rng(778899);
|
||||
let sidePotHands = 0;
|
||||
let returnHands = 0;
|
||||
|
||||
for (let hand = 0; hand < HANDS; hand++) {
|
||||
const { state } = playHand(random);
|
||||
const players = contributions(state);
|
||||
const inThePot = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||
if (inThePot <= 0) continue;
|
||||
|
||||
// Fold flags can leave nobody live, which the calculator deliberately
|
||||
// refuses to guess at — that's a dealer correction, not an invariant.
|
||||
if (players.every(p => p.folded)) continue;
|
||||
|
||||
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||
const returned = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
assert.equal(
|
||||
Money.toCents(layered + returned),
|
||||
Money.toCents(inThePot),
|
||||
`hand ${hand}: pot breakdown doesn't add up to what went in`
|
||||
);
|
||||
|
||||
if (PotCalculator.layers(players).length > 1) sidePotHands++;
|
||||
if (returned > 0) returnHands++;
|
||||
}
|
||||
|
||||
assert.ok(sidePotHands > 20, `expected side pots to occur; saw ${sidePotHands}`);
|
||||
assert.ok(returnHands > 20, `expected uncalled bets to occur; saw ${returnHands}`);
|
||||
});
|
||||
|
||||
test(`${HANDS} hands: what's left after returns is money somebody actually matched`, () => {
|
||||
// The rule the pot breakdown rests on: you are only called up to what somebody
|
||||
// else was willing to put in. Once the uncalled chips are handed back, no
|
||||
// player should still be sitting above every other player at the table.
|
||||
//
|
||||
// A single-eligible pot is *not* the thing to look for here — that's legitimate
|
||||
// when a player called and then folded, leaving dead money one contender
|
||||
// collects. The bug this guards against is subtler: chips nobody ever matched
|
||||
// being dressed up as a pot to play for.
|
||||
const random = rng(13579);
|
||||
for (let hand = 0; hand < HANDS; hand++) {
|
||||
const { state } = playHand(random);
|
||||
const players = contributions(state);
|
||||
if (players.every(p => p.folded)) continue;
|
||||
|
||||
const returns = PotCalculator.uncalledReturns(players);
|
||||
assert.ok(returns.length <= 1, `hand ${hand}: only the biggest bet can go uncalled`);
|
||||
|
||||
returns.forEach(r => {
|
||||
const player = players.find(p => p.id === r.id);
|
||||
assert.ok(r.amount <= player.contribution + 0.001, `hand ${hand}: returned more than was put in`);
|
||||
});
|
||||
|
||||
const returned = new Map(returns.map(r => [r.id, r.amount]));
|
||||
const capped = players.map(p => Money.toCents(p.contribution - (returned.get(p.id) || 0)));
|
||||
|
||||
capped.forEach((cents, index) => {
|
||||
const others = capped.filter((_, i) => i !== index);
|
||||
const mostAnyoneElsePaid = others.length ? Math.max(...others) : 0;
|
||||
assert.ok(
|
||||
cents <= mostAnyoneElsePaid,
|
||||
`hand ${hand}: a player is still ${Money.fromCents(cents - mostAnyoneElsePaid)} ` +
|
||||
`above everyone else after returns — that money was never matched`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test(`${HANDS} hands: paying the pot out settles it exactly`, () => {
|
||||
const random = rng(24680);
|
||||
for (let hand = 0; hand < HANDS; hand++) {
|
||||
const { state } = playHand(random);
|
||||
const players = contributions(state);
|
||||
if (players.every(p => p.folded)) continue;
|
||||
|
||||
// Take the uncalled money off first, the way the dealer would.
|
||||
const returned = new Map(PotCalculator.uncalledReturns(players).map(r => [r.id, r.amount]));
|
||||
const contested = players.map(p => ({
|
||||
...p,
|
||||
contribution: Money.round(p.contribution - (returned.get(p.id) || 0)),
|
||||
}));
|
||||
|
||||
let entries = PotCalculator.buildEntries(contested);
|
||||
const potBefore = PotCalculator.totalPot(entries);
|
||||
let paidOut = 0;
|
||||
let rounds = 0;
|
||||
|
||||
while (PotCalculator.getContenders(entries).length > 0 && rounds++ < 30) {
|
||||
const contenders = PotCalculator.getContenders(entries);
|
||||
const winner = contenders[Math.floor(random() * contenders.length)];
|
||||
const result = PotCalculator.resolveRound(entries, [winner.id]);
|
||||
paidOut += result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||
entries = result.updatedEntries;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
Money.toCents(paidOut),
|
||||
Money.toCents(potBefore),
|
||||
`hand ${hand}: paying out the pot left ${potBefore - paidOut} unaccounted for`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${HANDS} hands: the button and blinds land on real players`, () => {
|
||||
const random = rng(112233);
|
||||
for (let hand = 0; hand < HANDS; hand++) {
|
||||
const seatCount = 2 + Math.floor(random() * 9);
|
||||
const seated = [...Array(seatCount).keys()].filter(() => random() < 0.6);
|
||||
if (seated.length < 2) continue;
|
||||
|
||||
const state = makeTable({ seatCount, seated, dealerSeatIndex: Math.floor(random() * seatCount) });
|
||||
HoldemState.advanceButton(state);
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand, `hand ${hand}: button on an empty chair`);
|
||||
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand, `hand ${hand}: small blind on an empty chair`);
|
||||
assert.ok(state.seats[bbIndex].inHand, `hand ${hand}: big blind on an empty chair`);
|
||||
assert.notEqual(sbIndex, bbIndex, `hand ${hand}: both blinds on one player`);
|
||||
|
||||
if (seated.length === 2) {
|
||||
assert.ok(headsUp, `hand ${hand}: two players should be heads-up`);
|
||||
assert.equal(sbIndex, state.dealerSeatIndex, `hand ${hand}: heads-up button posts the small blind`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Loads the dealer's browser modules into Node so they can be tested directly.
|
||||
//
|
||||
// The modules are plain script-tag files: each one declares a `const` namespace and
|
||||
// ends with a `module.exports` guard. In the browser they find each other as globals,
|
||||
// so the same names have to exist on `globalThis` here or `Betting` can't reach
|
||||
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
|
||||
const path = require("path");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
|
||||
function load(name) {
|
||||
const mod = require(path.join(SRC, `${name}.js`));
|
||||
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
|
||||
return mod;
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const SettleUp = load("settleUp");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
const Render = require(path.join(SRC, "render.js")).Render;
|
||||
globalThis.Render = Render;
|
||||
|
||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
// in with `stack` chips each. Everything else is the app's own defaults, so a test
|
||||
// that forgets to set a field gets whatever a real dealer would get.
|
||||
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seatCount);
|
||||
state.smallBlind = smallBlind;
|
||||
state.bigBlind = bigBlind;
|
||||
state.dealerSeatIndex = dealerSeatIndex;
|
||||
|
||||
const sitting = seated || state.seats.map((_, i) => i);
|
||||
state.seats.forEach((player, index) => {
|
||||
player.inHand = sitting.includes(index);
|
||||
player.stack = sitting.includes(index) ? stack : null;
|
||||
player.name = `P${index + 1}`;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// Total chips the table is accountable for: what's in front of everyone, plus what
|
||||
// they've pushed in this street, plus what's already banked into the pot.
|
||||
function chipsOnTable(state) {
|
||||
return Money.round(
|
||||
state.seats.reduce(
|
||||
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Contributions in the shape PotCalculator wants them.
|
||||
function contributions(state) {
|
||||
return state.seats
|
||||
.filter(p => p.inHand)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
contribution: Betting.handTotal(p),
|
||||
folded: p.folded,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
SettleUp,
|
||||
Betting,
|
||||
HoldemState,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
// What happens when the saved table can't be read.
|
||||
//
|
||||
// An unreadable save can only become an empty table. What it must not do is become
|
||||
// one silently and then get written over by the first edit.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withStored(value, { readOnly = false } = {}) {
|
||||
const store = new Map();
|
||||
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem(key, val) {
|
||||
if (readOnly) throw new Error("QuotaExceededError");
|
||||
store.set(key, val);
|
||||
},
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.localStorage;
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test("nothing stored is a new table, not a failure", () => {
|
||||
withStored(undefined);
|
||||
const state = HoldemState.load();
|
||||
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
});
|
||||
|
||||
test("a good save loads and reports nothing", () => {
|
||||
const store = withStored(undefined);
|
||||
const original = HoldemState.createDefault();
|
||||
original.handNumber = 34;
|
||||
HoldemState.save(original);
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.equal(state.handNumber, 34);
|
||||
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
|
||||
});
|
||||
|
||||
test("a truncated save is reported rather than silently becoming an empty table", () => {
|
||||
const good = JSON.stringify(HoldemState.createDefault());
|
||||
withStored(good.slice(0, Math.floor(good.length * 0.7)));
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
|
||||
});
|
||||
|
||||
test("valid JSON of the wrong shape counts as unreadable too", () => {
|
||||
withStored(JSON.stringify({ hello: "world" }));
|
||||
|
||||
HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
|
||||
test("the unreadable copy is kept instead of being written over", () => {
|
||||
// This is the half that matters: whatever a person could have picked out of the
|
||||
// raw JSON used to be gone the moment the dealer touched a seat.
|
||||
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
|
||||
const store = withStored(broken);
|
||||
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
});
|
||||
|
||||
test("a later save doesn't touch the copy that was set aside", () => {
|
||||
const broken = '{"seats":[{"name":"Ann"';
|
||||
const store = withStored(broken);
|
||||
|
||||
const state = HoldemState.load();
|
||||
state.handNumber = 2;
|
||||
HoldemState.save(state);
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
|
||||
});
|
||||
|
||||
test("the listener fires once, not on every read", () => {
|
||||
withStored("{not json");
|
||||
let told = 0;
|
||||
HoldemState.onLoadFailed = () => { told++; };
|
||||
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("no room to keep the copy is not a crash", () => {
|
||||
// Storage that refuses writes is exactly the storage most likely to hand back a
|
||||
// half-written value in the first place.
|
||||
withStored("{not json", { readOnly: true });
|
||||
|
||||
let state;
|
||||
assert.doesNotThrow(() => { state = HoldemState.load(); });
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
|
||||
// whose blind it is, and pass the button round three times before anyone deals.
|
||||
// These are the cases the positional rules quietly get wrong.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||
|
||||
const potOf = state => HoldemState.totalPot(state);
|
||||
|
||||
describe("a player leaves in the middle of a hand", () => {
|
||||
test("their chips stay in the pot", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const before = potOf(state);
|
||||
assert.equal(before, 8);
|
||||
|
||||
state.seats[1].inHand = false; // dealer takes them out
|
||||
|
||||
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
|
||||
});
|
||||
|
||||
test("they cannot be paid out of a pot they walked away from", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
state.seats[1].inHand = false;
|
||||
|
||||
const players = state.seats
|
||||
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||
}));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
|
||||
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
|
||||
});
|
||||
|
||||
test("taking out the player who is to act moves the action on", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const acting = state.actingSeatIndex;
|
||||
|
||||
state.seats[acting].inHand = false;
|
||||
Betting.passAction(state, acting);
|
||||
|
||||
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
|
||||
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("an empty chair still contributes nothing, base pot or not", () => {
|
||||
// The table-wide default must not be read as an ante from seats nobody is in.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
|
||||
state.basePotAmount = 25;
|
||||
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a player joins in the middle of a hand", () => {
|
||||
test("they sit the hand out rather than joining a pot they never paid into", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
// Walks up and is dealt in — the app marks them folded for this hand.
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 100;
|
||||
state.seats[4].folded = true;
|
||||
|
||||
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
|
||||
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
|
||||
});
|
||||
|
||||
test("they are a full player again on the next hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 100;
|
||||
state.seats[4].folded = true;
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[4].folded, false);
|
||||
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
|
||||
});
|
||||
});
|
||||
|
||||
describe("passing the button around before the cards come out", () => {
|
||||
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const startingChips = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[1].committed, 1);
|
||||
assert.equal(state.seats[2].committed, 2);
|
||||
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
|
||||
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
|
||||
assert.equal(state.seats[0].committed, 2);
|
||||
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("passing it round many times still balances", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
|
||||
const startingChips = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
[2, 5, 7, 3, 0, 5].forEach(seat => {
|
||||
state.dealerSeatIndex = seat;
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
|
||||
});
|
||||
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
|
||||
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
|
||||
});
|
||||
|
||||
test("once somebody has acted, the blinds are settled", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
|
||||
|
||||
Betting.call(state, 3);
|
||||
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming the blinds by hand", () => {
|
||||
test("a named blind wins over the button order", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
|
||||
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(sbIndex, 3);
|
||||
assert.equal(bbIndex, 0);
|
||||
assert.equal(manual, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[3].committed, 1);
|
||||
assert.equal(state.seats[0].committed, 2);
|
||||
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
|
||||
});
|
||||
|
||||
test("a named blind on a player who isn't dealt in is ignored", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
|
||||
|
||||
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
|
||||
assert.ok(state.seats[sbIndex].inHand);
|
||||
});
|
||||
|
||||
test("both blinds on one player is refused", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.notEqual(sbIndex, bbIndex);
|
||||
});
|
||||
|
||||
test("naming blinds heads-up still acts in the right order", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[5].committed, 1);
|
||||
assert.equal(state.seats[2].committed, 2);
|
||||
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
|
||||
|
||||
Betting.call(state, 5);
|
||||
Betting.check(state, 2);
|
||||
Betting.closeStreet(state);
|
||||
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
|
||||
});
|
||||
|
||||
test("the naming only lasts the hand it was made for", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
Betting.beginHand(state);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.blindOverride, null, "blinds go back to following the button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first hand on a fresh table", () => {
|
||||
test("the button never starts on an empty chair", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
|
||||
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
|
||||
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand);
|
||||
assert.ok(state.seats[bbIndex].inHand);
|
||||
assert.notEqual(sbIndex, bbIndex);
|
||||
});
|
||||
|
||||
test("a table that empties out around the button recovers", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
|
||||
state.seats[3].inHand = false; // the player holding the button leaves
|
||||
HoldemState.ensureButtonOnPlayer(state);
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
[2, 3].forEach(i => { state.seats[i].inHand = false; });
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chips survive a messy table", () => {
|
||||
test("joining, leaving and passing the button never changes the chip count", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[6].stack = 100; // sitting down but not dealt in yet
|
||||
const startingChips = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
state.seats[6].inHand = true; // walks up mid-hand
|
||||
state.seats[6].folded = true;
|
||||
assert.equal(chipsOnTable(state), startingChips);
|
||||
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
state.seats[1].inHand = false; // and someone storms off
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
|
||||
assert.equal(
|
||||
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
|
||||
Money.toCents(startingChips),
|
||||
"and the pot plus the stacks is the whole table"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Money, HandHistoryExport } = require("./load");
|
||||
|
||||
describe("Money.format", () => {
|
||||
test("whole amounts drop the pointless cents", () => {
|
||||
assert.equal(Money.format(12), "$12");
|
||||
assert.equal(Money.format(450), "$450");
|
||||
assert.equal(Money.format(1250000), "$1,250,000");
|
||||
assert.equal(Money.format(0), "$0");
|
||||
});
|
||||
|
||||
test("amounts that carry cents still show them", () => {
|
||||
assert.equal(Money.format(12.5), "$12.50");
|
||||
assert.equal(Money.format(0.25), "$0.25");
|
||||
assert.equal(Money.format(3.01), "$3.01");
|
||||
});
|
||||
|
||||
test("a quarter-blind game reads exactly as it did", () => {
|
||||
// The case that would be ruined by dropping cents unconditionally.
|
||||
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||
});
|
||||
});
|
||||
|
||||
test("floating-point dust doesn't invent cents", () => {
|
||||
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||
assert.equal(Money.format(1.0000000001), "$1");
|
||||
});
|
||||
|
||||
test("signed amounts put the sign out front, both scales", () => {
|
||||
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||
assert.equal(Money.formatSigned(0), "$0");
|
||||
});
|
||||
|
||||
test("rubbish in doesn't produce rubbish out", () => {
|
||||
assert.equal(Money.format(null), "$0");
|
||||
assert.equal(Money.format(undefined), "$0");
|
||||
assert.equal(Money.format("nonsense"), "$0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Money.forSheet", () => {
|
||||
test("a bare number a spreadsheet will add up", () => {
|
||||
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||
assert.equal(Money.forSheet(12.5), "12.50");
|
||||
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||
});
|
||||
|
||||
test("no currency symbol and no thousands separators", () => {
|
||||
const written = Money.forSheet(1234567.89);
|
||||
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the exported CSV", () => {
|
||||
const history = [
|
||||
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||
];
|
||||
const standings = [
|
||||
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||
];
|
||||
|
||||
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||
const splitRow = line => {
|
||||
const fields = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ",") { fields.push(field); field = ""; }
|
||||
else field += c;
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
};
|
||||
|
||||
test("every money cell parses as a number", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
const lines = csv.split("\r\n");
|
||||
|
||||
lines.slice(1, 3).forEach(line => {
|
||||
const cell = splitRow(line)[2]; // Pot
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||
});
|
||||
|
||||
// Standings rows: every column after the name has to be numeric.
|
||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||
splitRow(line).slice(1).forEach(cell => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, []);
|
||||
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||
});
|
||||
|
||||
test("names and payout descriptions stay human", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||
});
|
||||
|
||||
test("a name with a comma is still quoted properly", () => {
|
||||
const csv = HandHistoryExport.toCsv([], [
|
||||
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||
]);
|
||||
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Noticing that the same table is open twice.
|
||||
//
|
||||
// Two tabs each hold their own copy in memory and each save writes the whole thing,
|
||||
// so the second one to act throws the first one's work away. This doesn't merge them
|
||||
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
|
||||
// notices, which is what lets them decide.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withWindow() {
|
||||
const listeners = [];
|
||||
globalThis.window = {
|
||||
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
|
||||
removeEventListener(type, fn) {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
listeners,
|
||||
fire(key) { listeners.forEach(fn => fn({ key })); },
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.window;
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test("a write to this table from elsewhere is noticed", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("somebody else's storage key is not our business", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire("some.other.app.state");
|
||||
win.fire(HoldemState.RECOVERY_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
assert.equal(told, 0);
|
||||
});
|
||||
|
||||
test("told once, however many times the other tab acts", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("watching twice doesn't listen twice", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.equal(win.listeners.length, 1);
|
||||
});
|
||||
|
||||
test("no listener set is not an error", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
});
|
||||
|
||||
test("no window at all is not an error", () => {
|
||||
// The same modules are loaded in Node by the test harness.
|
||||
delete globalThis.window;
|
||||
HoldemState.tabWatcher = null;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { PotCalculator } = require("./load");
|
||||
|
||||
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
|
||||
|
||||
describe("PotCalculator.layers", () => {
|
||||
test("one pot when everyone put in the same", () => {
|
||||
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
|
||||
assert.equal(layers.length, 1);
|
||||
assert.equal(layers[0].amount, 150);
|
||||
assert.equal(layers[0].label, "Main pot");
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("a short all-in opens a side pot only the deeper players can win", () => {
|
||||
// a is all-in for 20, b and c keep betting to 60.
|
||||
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
|
||||
assert.equal(layers.length, 2);
|
||||
|
||||
assert.equal(layers[0].amount, 60); // 20 x 3
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
|
||||
|
||||
assert.equal(layers[1].amount, 80); // 40 x 2
|
||||
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
|
||||
assert.equal(layers[1].label, "Side pot 1");
|
||||
});
|
||||
|
||||
test("folded money stays in the pot as dead money but wins nothing", () => {
|
||||
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
|
||||
assert.equal(layers.length, 1);
|
||||
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
|
||||
assert.ok(!layers[0].eligible.includes("folder"));
|
||||
});
|
||||
|
||||
test("layers with the same eligible players are announced as one pot", () => {
|
||||
// Two folders at different heights would each open a band, but a dealer
|
||||
// announces one pot between the two players who can actually win it.
|
||||
const layers = PotCalculator.layers([
|
||||
p("a", 100),
|
||||
p("b", 100),
|
||||
p("f1", 20, true),
|
||||
p("f2", 40, true),
|
||||
]);
|
||||
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
|
||||
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
|
||||
assert.equal(layers[0].amount, 260);
|
||||
});
|
||||
|
||||
test("layer amounts always sum to the contested money", () => {
|
||||
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
|
||||
const total = players.reduce((s, x) => s + x.contribution, 0);
|
||||
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
|
||||
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
|
||||
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
|
||||
});
|
||||
|
||||
test("nobody contributed means no pots, not an empty main pot", () => {
|
||||
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PotCalculator.uncalledReturns", () => {
|
||||
test("an overbet nobody called goes back to whoever bet it", () => {
|
||||
// b shoves 200, a calls only 50 and everyone else folds.
|
||||
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
|
||||
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
|
||||
});
|
||||
|
||||
test("a called bet leaves nothing to return", () => {
|
||||
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
|
||||
});
|
||||
|
||||
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
|
||||
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
|
||||
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
|
||||
});
|
||||
|
||||
test("with nobody live it stays quiet rather than refunding the table", () => {
|
||||
// Every fold flag set is a dealer mistake to correct, not a signal that
|
||||
// every chip on the felt was uncalled.
|
||||
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PotCalculator.resolveRound", () => {
|
||||
test("a single winner takes the layer they were matched for", () => {
|
||||
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||
const result = PotCalculator.resolveRound(entries, ["a"]);
|
||||
assert.equal(result.payoutPool, 100);
|
||||
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
|
||||
assert.equal(result.remainingContenders.length, 0);
|
||||
});
|
||||
|
||||
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
|
||||
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
|
||||
const result = PotCalculator.resolveRound(entries, ["short"]);
|
||||
assert.equal(result.capApplied, 20);
|
||||
assert.equal(result.payoutPool, 60, "20 from each of three players");
|
||||
// b and c still have 40 each left to play for.
|
||||
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
|
||||
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
|
||||
});
|
||||
|
||||
test("a chopped pot splits evenly", () => {
|
||||
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
|
||||
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
|
||||
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
|
||||
});
|
||||
|
||||
test("odd cents go to the winners in selection order and nothing is lost", () => {
|
||||
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
|
||||
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
|
||||
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
|
||||
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
|
||||
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
|
||||
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
|
||||
});
|
||||
|
||||
test("selecting nobody who can win is an error, not a silent no-op", () => {
|
||||
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
|
||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// Chips only ever leave this tool because a dealer meant them to. Starting the next
|
||||
// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
|
||||
// the UI now asks first, and the number it quotes has to be exactly what goes.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
// The same measure the confirmation uses.
|
||||
const unresolvedPot = state => HoldemState.unresolvedContributions(state);
|
||||
|
||||
describe("what starting the next hand destroys", () => {
|
||||
test("the quoted amount is exactly what leaves the table", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const before = chipsOnTable(state);
|
||||
const quoted = unresolvedPot(state);
|
||||
assert.ok(quoted > 0, "there is a pot to warn about");
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(
|
||||
Money.toCents(chipsOnTable(state)),
|
||||
Money.toCents(before - quoted),
|
||||
"the dealer must be told the real number, not an approximation"
|
||||
);
|
||||
});
|
||||
|
||||
test("chips still in front of players on the current street are counted too", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state); // blinds are `committed`, not yet banked
|
||||
Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
|
||||
|
||||
const before = chipsOnTable(state);
|
||||
const quoted = unresolvedPot(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
|
||||
assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
|
||||
});
|
||||
|
||||
test("a hand that was paid out properly leaves nothing to warn about", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
|
||||
});
|
||||
|
||||
test("settling then dealing on conserves every chip", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
|
||||
for (let hand = 0; hand < 5; hand++) {
|
||||
Betting.beginHand(state);
|
||||
const live = Betting.liveSeats(state);
|
||||
live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const winner = Betting.liveSeats(state)[0];
|
||||
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty table has no phantom pot to warn about", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [] });
|
||||
assert.equal(unresolvedPot(state), 0);
|
||||
});
|
||||
|
||||
test("a standing ante is never warned about, because it isn't lost", () => {
|
||||
// The table default is a stand-in for "everyone puts the same in every hand",
|
||||
// used by a dealer not tracking stacks at all. It re-applies to the next hand
|
||||
// rather than going anywhere, so counting it would nag this dealer before
|
||||
// every single hand — which is why the warning measures the fields
|
||||
// startNextHand actually clears rather than the pot badge.
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1] });
|
||||
state.basePotAmount = 25;
|
||||
state.seats.forEach(p => { p.amountOverride = null; });
|
||||
|
||||
// The base pot carries into the next hand, so nothing is being thrown away
|
||||
// and the dealer must not be asked to confirm anything.
|
||||
assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
|
||||
assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// When does a bet reopen the action? The most misread rule in poker, and the one a
|
||||
// home game argues about longest: a player shoving in less than a full raise does not
|
||||
// give everybody behind them another go.
|
||||
//
|
||||
// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting
|
||||
// to a player who has already acted and is not facing a full raise.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const actedFlags = state => state.seats
|
||||
.map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded }))
|
||||
.filter(s => s.live);
|
||||
|
||||
describe("a short all-in does not reopen the betting", () => {
|
||||
// Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls.
|
||||
// Seat 2 is all-in for 13 — three more, where a full raise needs eight.
|
||||
const shortAllInTable = () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 13;
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("players who already acted are not asked to act again", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.seats[0].hasActed, true, "seat 1 has called");
|
||||
assert.equal(state.seats[3].hasActed, true, "seat 4 has raised");
|
||||
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened");
|
||||
assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened");
|
||||
});
|
||||
|
||||
test("but they do still owe the difference", () => {
|
||||
const state = shortAllInTable();
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3");
|
||||
assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over");
|
||||
});
|
||||
|
||||
test("a player who had not acted yet keeps full rights", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted");
|
||||
Betting.allIn(state, 1);
|
||||
assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise");
|
||||
});
|
||||
|
||||
test("the minimum raise is not shrunk by the short all-in", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.lastRaiseSize, 8);
|
||||
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(state.lastRaiseSize, 8, "still the last *full* raise");
|
||||
assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8");
|
||||
});
|
||||
|
||||
test("calling the short all-in ends the street", () => {
|
||||
const state = shortAllInTable();
|
||||
Betting.allIn(state, 1);
|
||||
Betting.call(state, 2); // seat 3, who had full rights, just calls
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(Betting.isStreetComplete(state), true);
|
||||
});
|
||||
|
||||
test("no chips are created or lost by any of it", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 13;
|
||||
const start = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
Betting.allIn(state, 1);
|
||||
Betting.call(state, 2);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a full raise still reopens the betting", () => {
|
||||
test("an all-in that is a full raise or more reopens everyone", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 40;
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
|
||||
Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed
|
||||
|
||||
assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go");
|
||||
assert.equal(state.seats[3].hasActed, false, "and so does seat 4");
|
||||
assert.equal(state.lastRaiseSize, 30);
|
||||
assert.equal(Betting.minRaiseTo(state), 70);
|
||||
});
|
||||
|
||||
test("an ordinary raise reopens everyone", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(state.seats[3].hasActed, true);
|
||||
|
||||
Betting.raiseTo(state, 1, 10);
|
||||
|
||||
assert.equal(state.seats[3].hasActed, false);
|
||||
assert.equal(state.seats[0].hasActed, false);
|
||||
});
|
||||
|
||||
test("a raise exactly the size of the last one counts as full", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10); // raise of 8
|
||||
Betting.call(state, 0);
|
||||
|
||||
Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise
|
||||
|
||||
assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens");
|
||||
assert.equal(state.lastRaiseSize, 8);
|
||||
});
|
||||
|
||||
test("the first raise of a street is measured against the big blind", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
Betting.check(state, 2);
|
||||
Betting.closeStreet(state); // to the flop, no bet yet
|
||||
|
||||
const first = Betting.nextToAct(state, state.dealerSeatIndex);
|
||||
Betting.raiseTo(state, first, 2); // a bet of exactly one big blind
|
||||
|
||||
const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first);
|
||||
assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Values a dealer can actually produce with a typo, and the ones a corrupted save
|
||||
// can produce on its own. None of these should be able to move chips.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("a stack can never go negative", () => {
|
||||
test("a mistyped negative stack reads as empty, not as owing chips", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
|
||||
});
|
||||
|
||||
test("a negative stack can't be bet with", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
|
||||
|
||||
const moved = Betting.putIn(state, 0, 25);
|
||||
|
||||
assert.equal(moved, 0, "nothing moves out of an empty stack");
|
||||
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
|
||||
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
|
||||
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
|
||||
});
|
||||
|
||||
test("a negative table default doesn't hand everyone a negative stack", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats.forEach(p => { p.stack = null; });
|
||||
state.defaultStack = -100;
|
||||
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
|
||||
});
|
||||
|
||||
test("a negative stack doesn't make a player look like they can still bet", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("blinds that can't be posted", () => {
|
||||
test("negative blinds post nothing rather than paying players to sit down", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.smallBlind = -5;
|
||||
state.bigBlind = -10;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("an unreadable blind doesn't wedge the hand", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.bigBlind = NaN;
|
||||
state.smallBlind = 1;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
|
||||
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
|
||||
});
|
||||
|
||||
test("blinds nobody can cover put those players all-in and move on", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
|
||||
state.smallBlind = 500;
|
||||
state.bigBlind = 1000;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
|
||||
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
|
||||
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a saved table that comes back damaged", () => {
|
||||
test("unreadable storage falls back to a fresh table", () => {
|
||||
// load() has to survive whatever is in localStorage, because the alternative
|
||||
// is a dealer opening the tool mid-session to a blank screen.
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => "{not json at all",
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
|
||||
assert.equal(state.handNumber, 1);
|
||||
});
|
||||
|
||||
test("a save missing its seats is treated as no save at all", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
|
||||
});
|
||||
|
||||
test("a save from before a field existed gets the field, not undefined", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({
|
||||
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
|
||||
handNumber: 4,
|
||||
}),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.blindOverride, null);
|
||||
assert.equal(state.street, "preflop");
|
||||
state.seats.forEach(p => {
|
||||
assert.equal(p.committed, 0);
|
||||
assert.equal(p.hasActed, false);
|
||||
assert.equal(p.buyIns, 0);
|
||||
});
|
||||
// And it has to be playable, not just parseable.
|
||||
assert.doesNotThrow(() => Betting.beginHand(state));
|
||||
});
|
||||
|
||||
test("storage that refuses to be written doesn't take the table down", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error("QuotaExceededError"); },
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.createDefault();
|
||||
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
|
||||
globalThis.localStorage = original;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
|
||||
//
|
||||
// node tests/holdem/runUiAudit.js [baseUrl]
|
||||
//
|
||||
// Needs Playwright available (`npx playwright install chromium` once). It is
|
||||
// deliberately NOT wired into the unit-test suite or CI, because that suite is
|
||||
// dependency-free and runs in a second — this one needs a browser and a running
|
||||
// site. Run it after any change that adds or resizes a control.
|
||||
//
|
||||
// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
|
||||
// and call LayoutAudit.runAll(). Same checks, one viewport.
|
||||
const path = require("path");
|
||||
|
||||
const BASE = process.argv[2] || "http://localhost:5299";
|
||||
const AUDIT = path.join(__dirname, "uiAudit.js");
|
||||
|
||||
// Portrait phones, tablets, desktops, then the same phones on their side — landscape
|
||||
// is where this layout has broken most often, because the table is sized from width
|
||||
// and the action bar is fixed to a very short viewport.
|
||||
const VIEWPORTS = [
|
||||
[360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
|
||||
[412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
|
||||
[768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
|
||||
[1280, 900, "desktop"], [1600, 1000, "wide desktop"],
|
||||
[640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
|
||||
];
|
||||
|
||||
const OVERLAYS = [
|
||||
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
||||
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
||||
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
||||
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
||||
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
||||
];
|
||||
|
||||
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
||||
function seedTable() {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
|
||||
.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
for (let hand = 1; hand <= 4; hand++) {
|
||||
state.handHistory.push({
|
||||
handNumber: hand, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require("playwright"));
|
||||
} catch {
|
||||
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
||||
console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
let checks = 0;
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
||||
warnings.forEach(w => console.log(` note: ${w}`));
|
||||
if (failures.length === 0) {
|
||||
console.log("clean");
|
||||
return;
|
||||
}
|
||||
failures.forEach(f => {
|
||||
console.log(`\nFAIL ${f.where}`);
|
||||
f.offenders.forEach(o => console.log(` ${o}`));
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,95 @@
|
||||
// What happens when the browser refuses to save.
|
||||
//
|
||||
// The table keeps working — that was always true and is still the right behaviour.
|
||||
// What was missing is anybody being told, which is the same shape of fault the bank
|
||||
// check exists for: silent until it's too late to act on.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, makeTable } = require("./load");
|
||||
|
||||
function withStorage(behaviour) {
|
||||
const calls = [];
|
||||
globalThis.localStorage = {
|
||||
setItem(key, value) {
|
||||
calls.push({ key, value });
|
||||
if (behaviour === "throw") {
|
||||
const error = new Error("QuotaExceededError");
|
||||
error.name = "QuotaExceededError";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getItem: () => null,
|
||||
removeItem() {},
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.saveFailed = false;
|
||||
HoldemState.onSaveFailed = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.localStorage;
|
||||
HoldemState.saveFailed = false;
|
||||
HoldemState.onSaveFailed = null;
|
||||
});
|
||||
|
||||
test("a save that works says so and leaves no mark", () => {
|
||||
withStorage("ok");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
|
||||
assert.equal(HoldemState.save(state), true);
|
||||
assert.equal(HoldemState.saveFailed, false);
|
||||
});
|
||||
|
||||
test("a save that fails says so rather than throwing", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
|
||||
assert.equal(HoldemState.save(state), false);
|
||||
assert.equal(HoldemState.saveFailed, true);
|
||||
});
|
||||
|
||||
test("the table is told once, not on every action", () => {
|
||||
// A dealer taps the bar thirty times a hand and doesn't need thirty warnings.
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
let told = 0;
|
||||
HoldemState.onSaveFailed = () => { told++; };
|
||||
|
||||
for (let i = 0; i < 20; i++) HoldemState.save(state);
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("the failure is handed the error, so the reason can be reported later", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
let seen = null;
|
||||
HoldemState.onSaveFailed = error => { seen = error; };
|
||||
|
||||
HoldemState.save(state);
|
||||
|
||||
assert.equal(seen && seen.name, "QuotaExceededError");
|
||||
});
|
||||
|
||||
test("a failing save still doesn't stop the table", () => {
|
||||
// The chips have already moved in memory; refusing to continue would be worse
|
||||
// than not persisting.
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].stack = 175;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.save(state));
|
||||
assert.equal(state.seats[0].stack, 175);
|
||||
});
|
||||
|
||||
test("no listener is not an error", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
HoldemState.onSaveFailed = null;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.save(state));
|
||||
assert.equal(HoldemState.saveFailed, true);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Which fields replace their contents when a dealer reaches for them.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
// Enough of an input to answer the question, without a DOM.
|
||||
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
|
||||
|
||||
test("a number box selects", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
|
||||
});
|
||||
|
||||
test("a decimal keypad box selects", () => {
|
||||
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
|
||||
// a number pad without the browser's spinner arrows.
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
|
||||
});
|
||||
|
||||
test("a name does not", () => {
|
||||
// Selecting all of a name on focus is the same mistake pointing the other way:
|
||||
// a name is a thing you edit, not a suggestion you replace.
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
|
||||
});
|
||||
|
||||
test("a colour swatch does not", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
|
||||
});
|
||||
|
||||
test("a checkbox does not", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
|
||||
});
|
||||
|
||||
test("a textarea does not, whatever it is asked", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
|
||||
});
|
||||
|
||||
test("a select does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
|
||||
});
|
||||
|
||||
test("case doesn't matter", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
|
||||
});
|
||||
|
||||
test("nothing at all is not a field", () => {
|
||||
assert.equal(Money.selectsOnFocus(null), false);
|
||||
assert.equal(Money.selectsOnFocus(undefined), false);
|
||||
});
|
||||
|
||||
test("a field with no type stated is a text box, so it does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// Turning the standings into "who hands cash to whom".
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
|
||||
|
||||
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
|
||||
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
|
||||
const paidTo = (plan, name) =>
|
||||
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
const paidBy = (plan, name) =>
|
||||
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
test("one loser, one winner", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("everybody flat means nothing to do", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("nobody at all means nothing to do", () => {
|
||||
const plan = SettleUp.plan([]);
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("each player ends up square", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
|
||||
table.forEach(row => {
|
||||
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
|
||||
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
|
||||
});
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("at most one payment fewer than there are people", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
assert.ok(plan.payments.length <= table.length - 1,
|
||||
`${plan.payments.length} payments for ${table.length} players`);
|
||||
});
|
||||
|
||||
test("a player who owes two people has both lines together", () => {
|
||||
// The list gets read out one player at a time, so a payer's obligations must not
|
||||
// be split up by somebody else's.
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
|
||||
});
|
||||
|
||||
test("cents survive", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
|
||||
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("more chips than cash leaves somebody unpaid, and says so", () => {
|
||||
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
|
||||
// cannot all be paid, and pretending otherwise is how a real person ends up short.
|
||||
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
|
||||
assert.equal(plan.shortBy, 25);
|
||||
assert.equal(plan.overBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("less chips than cash leaves a payer with nowhere to send it", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
|
||||
assert.equal(plan.overBy, 25);
|
||||
assert.equal(plan.shortBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("no payment is ever for nothing", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.amount > 0));
|
||||
assert.equal(plan.payments.length, 1);
|
||||
});
|
||||
|
||||
test("nobody pays themselves", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.from !== p.to));
|
||||
});
|
||||
|
||||
test("the payments total what the winners are owed", () => {
|
||||
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
|
||||
const plan = SettleUp.plan(table);
|
||||
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
assert.equal(Money.round(moved), 60);
|
||||
});
|
||||
|
||||
test("it settles a real table's standings, not just hand-made numbers", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].buyIns = 100;
|
||||
state.seats[1].buyIns = 100;
|
||||
state.seats[2].buyIns = 100;
|
||||
state.seats[0].stack = 175;
|
||||
state.seats[1].stack = 90;
|
||||
state.seats[2].stack = 35;
|
||||
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
const plan = SettleUp.plan(rows);
|
||||
|
||||
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
assert.equal(plan.balanced, true);
|
||||
rows.forEach(row => {
|
||||
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// The legend and the key map must not drift apart.
|
||||
//
|
||||
// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
|
||||
// so a dealer who learned the first three from the action bar had no way to find out
|
||||
// the other two existed. The guard against that happening again is this file.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const Shortcuts = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
|
||||
|
||||
test("every shortcut has a line in the legend", () => {
|
||||
const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
|
||||
const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
|
||||
assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
|
||||
});
|
||||
|
||||
test("the legend doesn't describe shortcuts that don't exist", () => {
|
||||
const real = new Set(Object.values(Shortcuts.KEYS));
|
||||
const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
|
||||
assert.deepEqual(invented, []);
|
||||
});
|
||||
|
||||
test("every legend line names its keys and says what they do", () => {
|
||||
Shortcuts.LEGEND.forEach(entry => {
|
||||
assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
|
||||
assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
|
||||
assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the keys named match the keys bound", () => {
|
||||
// "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
|
||||
const spoken = { space: " ", enter: "enter", esc: "escape" };
|
||||
Shortcuts.LEGEND.forEach(entry => {
|
||||
entry.keys.forEach(label => {
|
||||
const key = spoken[label.toLowerCase()] || label.toLowerCase();
|
||||
assert.equal(Shortcuts.KEYS[key], entry.action,
|
||||
`the legend says ${label} is ${entry.action}, the map disagrees`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("no two lines claim the same key", () => {
|
||||
const seen = new Set();
|
||||
Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
|
||||
assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
|
||||
seen.add(key.toLowerCase());
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
|
||||
//
|
||||
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
|
||||
// rather than a whole DOM: the only things it asks about are what has focus and
|
||||
// whether an overlay is open.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const Shortcuts = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
|
||||
|
||||
// A stand-in for a focused element. `matches` answers the same selector list the
|
||||
// real one would, which is the whole of what focusedControlOwnsKey looks at.
|
||||
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
|
||||
return {
|
||||
tagName,
|
||||
isContentEditable,
|
||||
matches: selector => selectors.some(s => selector.includes(s)),
|
||||
};
|
||||
}
|
||||
|
||||
const BUTTON = element("BUTTON", { selectors: ["button"] });
|
||||
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
|
||||
const LINK = element("A", { selectors: ["a[href]"] });
|
||||
const DIV = element("DIV");
|
||||
const TEXT_FIELD = element("INPUT");
|
||||
|
||||
// Runs one keypress against a fresh Shortcuts and reports which action fired.
|
||||
function press(key, { focused = DIV, overlays = [] } = {}) {
|
||||
const fired = [];
|
||||
let defaultPrevented = false;
|
||||
|
||||
globalThis.document = {
|
||||
activeElement: focused,
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => overlays,
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
for (const action of Object.values(Shortcuts.KEYS)) {
|
||||
handlers[action] = () => { fired.push(action); return true; };
|
||||
}
|
||||
Shortcuts.handlers = handlers;
|
||||
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
|
||||
|
||||
return { fired: fired[0] || null, defaultPrevented };
|
||||
}
|
||||
|
||||
test.after(() => { delete globalThis.document; });
|
||||
|
||||
test("Enter on a focused button is left to the button", () => {
|
||||
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("Space on a focused button is left to the button", () => {
|
||||
assert.equal(press(" ", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("a key left to the control is not swallowed either", () => {
|
||||
// preventDefault would stop the browser activating the button, which is the
|
||||
// half of the bug that turned "wrong action" into "no action".
|
||||
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
|
||||
});
|
||||
|
||||
test("summary and links own Enter and Space too", () => {
|
||||
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
|
||||
assert.equal(press(" ", { focused: LINK }).fired, null);
|
||||
});
|
||||
|
||||
test("Space still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press(" ", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("Enter still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("the letter shortcuts stay live with a button focused", () => {
|
||||
// A button does nothing with "f", so there is no press to hand back — and a
|
||||
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
|
||||
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
|
||||
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
|
||||
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
|
||||
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
|
||||
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
|
||||
});
|
||||
|
||||
test("typing still silences everything but Escape", () => {
|
||||
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
|
||||
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
|
||||
});
|
||||
|
||||
test("an open overlay still owns the keyboard", () => {
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
|
||||
});
|
||||
|
||||
test("a focused control wins before the overlay check, not after", () => {
|
||||
// Both would decline here; the point is that neither path fires an action.
|
||||
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
|
||||
});
|
||||
|
||||
test("modified keys are never shortcuts", () => {
|
||||
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
|
||||
const fired = [];
|
||||
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
|
||||
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
|
||||
assert.deepEqual(fired, []);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { SplitCalculator, Money } = require("./load");
|
||||
|
||||
describe("SplitCalculator.split", () => {
|
||||
test("an even chop hands everyone the same stack", () => {
|
||||
const result = SplitCalculator.split(90, 3, 1);
|
||||
assert.equal(result.each, 30);
|
||||
assert.equal(result.oddChips, 0);
|
||||
assert.equal(result.dust, 0);
|
||||
assert.equal(SplitCalculator.accountedFor(result), 90);
|
||||
});
|
||||
|
||||
test("what won't divide is called out as odd chips, not rolled into a stack", () => {
|
||||
// $100 three ways in $5 chips: 20 chips, 6 each, 2 left over.
|
||||
const result = SplitCalculator.split(100, 3, 5);
|
||||
assert.equal(result.each, 30);
|
||||
assert.equal(result.oddChips, 2);
|
||||
assert.equal(SplitCalculator.accountedFor(result), 100);
|
||||
});
|
||||
|
||||
test("a pot that isn't a whole number of chips surfaces the remainder", () => {
|
||||
// $47 in $5 chips: 9 chips ($45) and $2 that no chip in play can pay.
|
||||
const result = SplitCalculator.split(47, 2, 5);
|
||||
assert.equal(result.dust, 2, "the dealer picked a denomination the pot can't make");
|
||||
assert.equal(SplitCalculator.accountedFor(result), 47);
|
||||
});
|
||||
|
||||
test("every split accounts for the whole pot", () => {
|
||||
const chips = [0.25, 0.5, 1, 5, 25, 100];
|
||||
for (let pot = 1; pot <= 400; pot += 7) {
|
||||
for (let ways = 2; ways <= 6; ways++) {
|
||||
chips.forEach(chip => {
|
||||
const result = SplitCalculator.split(pot, ways, chip);
|
||||
assert.equal(
|
||||
Money.toCents(SplitCalculator.accountedFor(result)),
|
||||
Money.toCents(pot),
|
||||
`${pot} split ${ways} ways in ${chip} chips lost money`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("odd chips are always fewer than the number of players", () => {
|
||||
for (let pot = 1; pot <= 200; pot += 3) {
|
||||
for (let ways = 2; ways <= 8; ways++) {
|
||||
const result = SplitCalculator.split(pot, ways, 1);
|
||||
assert.ok(result.oddChips < ways, "a whole extra chip each would have been dealt out");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty pot isn't a valid split", () => {
|
||||
assert.equal(SplitCalculator.split(0, 3, 1).valid, false);
|
||||
assert.equal(SplitCalculator.split(-5, 3, 1).valid, false);
|
||||
});
|
||||
|
||||
test("nonsense inputs fall back to something a dealer can read", () => {
|
||||
const result = SplitCalculator.split(50, 0, 0);
|
||||
assert.equal(result.players, 1, "a split has to be at least one way");
|
||||
assert.equal(result.chip, 0.01, "and at least one cent a chip");
|
||||
assert.equal(SplitCalculator.accountedFor(result), 50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { HoldemState, makeTable } = require("./load");
|
||||
|
||||
describe("HoldemState.advanceButton", () => {
|
||||
test("the button moves to the next player, not the next empty chair", () => {
|
||||
// Four players spread around a ten-seat table: 0, 3, 4, 8.
|
||||
const state = makeTable({ seatCount: 10, seated: [0, 3, 4, 8], dealerSeatIndex: 0 });
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 3, "skips the three empty chairs between");
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 4);
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 8);
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 0, "wraps back round to the first player");
|
||||
});
|
||||
|
||||
test("every player gets the button once per orbit", () => {
|
||||
const seated = [1, 2, 6, 7, 9];
|
||||
const state = makeTable({ seatCount: 12, seated, dealerSeatIndex: 1 });
|
||||
const visited = [];
|
||||
for (let i = 0; i < seated.length; i++) {
|
||||
visited.push(state.dealerSeatIndex);
|
||||
HoldemState.advanceButton(state);
|
||||
}
|
||||
assert.deepEqual(visited.slice().sort((a, b) => a - b), seated);
|
||||
});
|
||||
|
||||
test("it still moves when nobody is dealt in, rather than looking stuck", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [], dealerSeatIndex: 2 });
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 3);
|
||||
});
|
||||
|
||||
test("counterclockwise play runs the button the other way", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 2, 4], dealerSeatIndex: 4 });
|
||||
state.direction = "counterclockwise";
|
||||
HoldemState.advanceButton(state);
|
||||
assert.equal(state.dealerSeatIndex, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HoldemState.getBlindSeats", () => {
|
||||
test("blinds follow the players, not the chairs left of the button", () => {
|
||||
// Seats 1 and 2 are empty, so the blinds belong on 3 and 7.
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 3, 7], dealerSeatIndex: 0 });
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, false);
|
||||
assert.equal(sbIndex, 3);
|
||||
assert.equal(bbIndex, 7);
|
||||
});
|
||||
|
||||
test("heads-up, the button posts the small blind", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, 2, "the button is the small blind heads-up");
|
||||
assert.equal(bbIndex, 5);
|
||||
});
|
||||
|
||||
test("heads-up with the button on an empty chair still posts both blinds", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [1, 4], dealerSeatIndex: 0 });
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, 1);
|
||||
assert.equal(bbIndex, 4);
|
||||
assert.notEqual(sbIndex, bbIndex, "two blinds never land on one player");
|
||||
});
|
||||
|
||||
test("fewer than two players means no blinds to post", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [3], dealerSeatIndex: 0 });
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(sbIndex, null);
|
||||
assert.equal(bbIndex, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HoldemState.sessionStandings", () => {
|
||||
test("a player's cash position counts chips in front plus chips in the pot", () => {
|
||||
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 0 });
|
||||
const [a] = state.seats;
|
||||
a.buyIns = 100;
|
||||
a.stack = 60;
|
||||
a.amountOverride = 25; // banked into the hand in progress
|
||||
a.committed = 15; // still in front of them on this street
|
||||
|
||||
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 0 }]);
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
const row = standings.find(s => s.id === a.id);
|
||||
|
||||
// 60 in front + 40 in the pot - 100 bought in = level.
|
||||
assert.equal(row.cashPosition, 0, "money in the pot is still theirs until it's won");
|
||||
assert.equal(row.boughtIn, 100);
|
||||
assert.equal(row.chipsNow, 60);
|
||||
});
|
||||
|
||||
test("hand results accumulate across the session", () => {
|
||||
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
|
||||
const [a, b] = state.seats;
|
||||
a.amountOverride = 10;
|
||||
b.amountOverride = 10;
|
||||
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 20 }]);
|
||||
|
||||
a.amountOverride = 5;
|
||||
b.amountOverride = 5;
|
||||
HoldemState.recordHand(state, [{ id: b.id, label: "P2", amount: 10 }]);
|
||||
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
assert.equal(standings.find(s => s.id === a.id).net, 5);
|
||||
assert.equal(standings.find(s => s.id === b.id).net, -5);
|
||||
assert.equal(state.handNumber, 3, "the counter moved on twice");
|
||||
});
|
||||
|
||||
test("the history is capped so a long session can still be saved", () => {
|
||||
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
|
||||
for (let i = 0; i < HoldemState.MAX_HISTORY_ENTRIES + 25; i++) {
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 1 }]);
|
||||
}
|
||||
assert.equal(state.handHistory.length, HoldemState.MAX_HISTORY_ENTRIES);
|
||||
assert.equal(state.handHistory[0].handNumber, HoldemState.MAX_HISTORY_ENTRIES + 25, "newest first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HoldemState.resizeSeats", () => {
|
||||
test("the table is clamped to a size a dealer could actually run", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
HoldemState.resizeSeats(state, 100);
|
||||
assert.equal(state.seatCount, 24);
|
||||
HoldemState.resizeSeats(state, 1);
|
||||
assert.equal(state.seatCount, 2);
|
||||
});
|
||||
|
||||
test("an unreadable seat count falls back to a standard table", () => {
|
||||
// Empty or non-numeric input (and zero, which is equally meaningless)
|
||||
// lands on the default rather than a two-seat table.
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
HoldemState.resizeSeats(state, NaN);
|
||||
assert.equal(state.seatCount, HoldemState.DEFAULT_SEAT_COUNT);
|
||||
});
|
||||
|
||||
test("shrinking past the button brings it back onto the table", () => {
|
||||
const state = makeTable({ seatCount: 9, dealerSeatIndex: 8 });
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
assert.ok(state.dealerSeatIndex < 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Two players changing chairs.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
test("the whole seat moves", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].cards = ["AS", "KD"];
|
||||
state.seats[0].stack = 175;
|
||||
state.seats[4].name = "";
|
||||
|
||||
HoldemState.swapSeats(state, 0, 4);
|
||||
|
||||
assert.equal(state.seats[4].name, "Ann");
|
||||
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
|
||||
assert.equal(state.seats[4].stack, 175);
|
||||
assert.equal(state.seats[0].name, "");
|
||||
});
|
||||
|
||||
test("what they have in the pot goes with them", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
const posted = state.seats.map(p => p.committed);
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
HoldemState.swapSeats(state, 1, 4);
|
||||
|
||||
assert.equal(state.seats[4].committed, posted[1]);
|
||||
assert.equal(state.seats[1].committed, posted[4]);
|
||||
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
|
||||
});
|
||||
|
||||
test("the button stays on the chair, not on the player", () => {
|
||||
// A card room moves the button around the table, not around the people. Somebody
|
||||
// who sits down in the big blind's chair is the big blind.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[0].name = "Ann";
|
||||
|
||||
HoldemState.swapSeats(state, 0, 2);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 0);
|
||||
assert.notEqual(state.seats[0].name, "Ann");
|
||||
});
|
||||
|
||||
test("the blinds follow the chairs too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const before = HoldemState.getBlindSeats(state);
|
||||
|
||||
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
|
||||
const after = HoldemState.getBlindSeats(state);
|
||||
|
||||
assert.equal(after.sbIndex, before.sbIndex);
|
||||
assert.equal(after.bbIndex, before.bbIndex);
|
||||
});
|
||||
|
||||
test("swapping a seat with itself does nothing and says so", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("a chair that isn't there is refused rather than swallowed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
|
||||
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("swapping twice puts everything back", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
HoldemState.swapSeats(state, 1, 3);
|
||||
HoldemState.swapSeats(state, 1, 3);
|
||||
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("moving into an empty chair leaves an empty chair behind", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[1].name = "Ben";
|
||||
|
||||
HoldemState.swapSeats(state, 1, 5);
|
||||
|
||||
assert.equal(state.seats[5].name, "Ben");
|
||||
assert.equal(state.seats[5].inHand, true);
|
||||
assert.equal(state.seats[1].inHand, false);
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
// Layout and touch audits for the dealer, written once so they stop being written
|
||||
// again every round.
|
||||
//
|
||||
// These checks have been re-typed into a browser console at least six times over
|
||||
// the course of building this thing, and each time they only covered whatever had
|
||||
// just been changed. That is exactly how the standings table shipped with a
|
||||
// zero-pixel player-name column and survived two rounds: the audit that would have
|
||||
// caught it existed only in a previous session's scrollback.
|
||||
//
|
||||
// This file is deliberately dependency-free and browser-side. It is injected into
|
||||
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
|
||||
// package.json, and no browser automation of its own — anyone can also paste it
|
||||
// into devtools and call LayoutAudit.runAll().
|
||||
const LayoutAudit = {
|
||||
// Everything a dealer has to be able to hit.
|
||||
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
|
||||
|
||||
describe(el) {
|
||||
if (!el) return "(nothing)";
|
||||
const id = el.id ? `#${el.id}` : "";
|
||||
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
|
||||
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
|
||||
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
|
||||
},
|
||||
|
||||
visibleControls() {
|
||||
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const style = getComputedStyle(el);
|
||||
return style.visibility !== "hidden" && style.display !== "none";
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Touch (see the mobile round) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
|
||||
// seat as its fold shortcut, so that wait is on the critical path.
|
||||
touchAction() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => getComputedStyle(el).touchAction === "auto")
|
||||
.map(el => this.describe(el));
|
||||
return { name: "touch-action", offenders, checked: this.visibleControls().length };
|
||||
},
|
||||
|
||||
tapHighlight() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => {
|
||||
const colour = getComputedStyle(el).webkitTapHighlightColor;
|
||||
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
|
||||
})
|
||||
.map(el => this.describe(el));
|
||||
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
|
||||
},
|
||||
|
||||
// Text selection must stay ON for anything a dealer types into.
|
||||
textSelectable() {
|
||||
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
|
||||
.filter(el => el.offsetParent !== null)
|
||||
.filter(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return (style.webkitUserSelect || style.userSelect) === "none";
|
||||
})
|
||||
.map(el => this.describe(el));
|
||||
return { name: "fields stay selectable", offenders };
|
||||
},
|
||||
|
||||
// ---- Reachability (see the occlusion round) ----
|
||||
|
||||
// A control covered by something else is unreachable however good it looks.
|
||||
// Sampling several points matters: a button you can only hit in one corner is
|
||||
// covered for practical purposes.
|
||||
// Can this element be brought into view by scrolling? Something below the fold on
|
||||
// a page that scrolls is perfectly reachable; something below the fold in a panel
|
||||
// that *doesn't* scroll is lost. The first version of this check didn't make the
|
||||
// distinction and reported every link under the fold as a fault.
|
||||
reachableByScrolling(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (getComputedStyle(el).position === "fixed") return false;
|
||||
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
|
||||
if (!scrolls) continue;
|
||||
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
|
||||
const room = node === document.body
|
||||
? document.documentElement.scrollHeight - innerHeight - scrollY
|
||||
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
||||
if (rect.top >= box.bottom && room > 0) return true;
|
||||
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||
const offenders = [];
|
||||
const warnings = [];
|
||||
[...document.querySelectorAll(selector)].forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
|
||||
rect.right <= 0 || rect.left >= innerWidth;
|
||||
if (outsideViewport) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const points = [
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
|
||||
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
|
||||
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
|
||||
].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
|
||||
if (points.length === 0) return;
|
||||
|
||||
let blocked = 0;
|
||||
let testable = 0;
|
||||
let blockedBy = null;
|
||||
points.forEach(([x, y]) => {
|
||||
const top = document.elementFromPoint(x, y);
|
||||
// A null result means the point isn't hit-testable — typically it sits
|
||||
// inside the element but outside a scrollable ancestor's clip, i.e. the
|
||||
// control is scrolled out of view rather than covered by anything.
|
||||
// Counting that as "covered by null" was reporting a perfectly
|
||||
// reachable button in a sheet that scrolls.
|
||||
if (!top) return;
|
||||
testable++;
|
||||
if (el.contains(top) || top === el || top.contains(el)) return;
|
||||
// Seats ring a round table and overlap each other at the rim by
|
||||
// design; that is crowding, not an overlay hiding a control.
|
||||
//
|
||||
// But only the seat's *body* is harmless. A neighbouring seat's own
|
||||
// controls sitting on top of this one is a control being stolen, and
|
||||
// exempting the whole seat subtree hid exactly that: an enlarged
|
||||
// dealer-chip hit area was taking the centre of the seat three
|
||||
// places round the table, and this check called it crowding and
|
||||
// moved on.
|
||||
const neighbourSeat = top.closest && top.closest(".seat");
|
||||
if (neighbourSeat) {
|
||||
const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']");
|
||||
if (!isControl) return;
|
||||
}
|
||||
blocked++;
|
||||
if (!blockedBy) blockedBy = this.describe(top);
|
||||
});
|
||||
|
||||
if (testable === 0) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blocked === 0) return;
|
||||
|
||||
const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
|
||||
|
||||
// A toast is transient by design: it dismisses itself and carries a close
|
||||
// button, and on a desktop it necessarily lands over a table that fills the
|
||||
// content column. Partial overlap from one is a fact of the layout, not a
|
||||
// fault. Being *fully* covered by a toast is still a failure though — that
|
||||
// was a real bug once, where a seat sat exactly under the Undo button and a
|
||||
// tap meant for a player took back the last action instead.
|
||||
const transient = blockedBy && /toast/i.test(blockedBy);
|
||||
if (transient && blocked < testable) warnings.push(note);
|
||||
else offenders.push(note);
|
||||
});
|
||||
return { name: "nothing important is covered", offenders, warnings };
|
||||
},
|
||||
|
||||
// ---- Layout (the check that would have caught the standings regression) ----
|
||||
|
||||
// A grid column squeezed to nothing doesn't truncate its text, it erases it.
|
||||
// This is the one that was missing: the player-name column sat at 0px through
|
||||
// two rounds because nothing ever asked whether a column had survived.
|
||||
collapsedColumns() {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||||
// Nothing inside something the page has hidden. A hidden element's children
|
||||
// all measure zero wide, so the standings header — which is hidden along
|
||||
// with its table until somebody has bought in — was reported as three
|
||||
// collapsed columns on every empty table. A column nobody can see hasn't
|
||||
// collapsed; it isn't there.
|
||||
if (row.closest("[hidden]")) return;
|
||||
if (getComputedStyle(row).display !== "grid") return;
|
||||
[...row.children].forEach(cell => {
|
||||
if (getComputedStyle(cell).display === "none") return;
|
||||
const hasText = (cell.textContent || "").trim().length > 0;
|
||||
if (hasText && cell.getBoundingClientRect().width < 4) {
|
||||
offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { name: "no column collapsed to nothing", offenders };
|
||||
},
|
||||
|
||||
// Text actually painted outside the box meant to contain it — which on a table of
|
||||
// overlapping seats means painted on top of a neighbour.
|
||||
//
|
||||
// The clipping check is the whole subtlety, and the first version of this got it
|
||||
// wrong. A Range's bounding rect measures the *laid out* text, which happily runs
|
||||
// past the element when `overflow: hidden` is quietly clipping it — so an
|
||||
// ellipsised name was reported as spilling when nothing was drawn outside at all.
|
||||
// Only overflow that is actually visible can paint over a neighbour.
|
||||
textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll(selector)].forEach(el => {
|
||||
const text = (el.textContent || "").trim();
|
||||
if (!text) return;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
|
||||
if (clipsItself) return;
|
||||
|
||||
const container = el.closest(".seat") || el.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const drawn = range.getBoundingClientRect();
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
|
||||
offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
|
||||
}
|
||||
});
|
||||
return { name: "no text painted outside its box", offenders };
|
||||
},
|
||||
|
||||
horizontalOverflow() {
|
||||
const offenders = document.documentElement.scrollWidth > innerWidth + 1
|
||||
? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
|
||||
: [];
|
||||
return { name: "page never scrolls sideways", offenders };
|
||||
},
|
||||
|
||||
// iOS zooms the whole page when a field under 16px takes focus, and the dealer
|
||||
// is then pinching back out mid-hand.
|
||||
fieldFontSize() {
|
||||
const offenders = [...document.querySelectorAll("input, textarea, select")]
|
||||
.filter(el => el.offsetParent !== null)
|
||||
.filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
|
||||
.filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
|
||||
.map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
|
||||
return { name: "no field small enough to trigger iOS zoom", offenders };
|
||||
},
|
||||
|
||||
// ---- Contrast ----
|
||||
//
|
||||
// This check has been got wrong twice before, both times by being too clever about
|
||||
// backgrounds. The first version read only `background-color`, so every sheet
|
||||
// painted with a gradient was measured against transparency and the dealer puck's
|
||||
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
|
||||
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
|
||||
//
|
||||
// So it is built to be incapable of false positives instead of trying to be exact.
|
||||
// Backgrounds composite properly through alpha, and where a gradient is involved
|
||||
// every one of its colour stops is tried and the *most flattering* one is used. A
|
||||
// finding therefore means the text fails even under the reading most generous to
|
||||
// it, which is a claim worth making. Text that can't be resolved to any opaque
|
||||
// background is reported separately as unmeasured rather than guessed at.
|
||||
|
||||
parseColour(value) {
|
||||
const match = String(value).match(/rgba?\(([^)]+)\)/);
|
||||
if (!match) return null;
|
||||
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
|
||||
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
|
||||
},
|
||||
|
||||
// Colour stops out of any gradient in a background-image.
|
||||
gradientStops(backgroundImage) {
|
||||
if (!backgroundImage || backgroundImage === "none") return [];
|
||||
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
|
||||
.map(c => this.parseColour(c))
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
over(top, bottom) {
|
||||
const a = top.a;
|
||||
return {
|
||||
r: top.r * a + bottom.r * (1 - a),
|
||||
g: top.g * a + bottom.g * (1 - a),
|
||||
b: top.b * a + bottom.b * (1 - a),
|
||||
a: 1,
|
||||
};
|
||||
},
|
||||
|
||||
relativeLuminance({ r, g, b }) {
|
||||
const channel = v => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
},
|
||||
|
||||
contrastRatio(fg, bg) {
|
||||
const a = this.relativeLuminance(fg);
|
||||
const b = this.relativeLuminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
},
|
||||
|
||||
// Every background this text could actually be sitting on.
|
||||
//
|
||||
// Layers are collected from the element outwards and stop at the first fully
|
||||
// opaque one, because nothing behind that is visible. Getting this wrong in the
|
||||
// lenient direction is just as bad as the old false positives: an earlier version
|
||||
// kept walking past an opaque gradient and offered the page background as a
|
||||
// candidate, which scored white-on-a-mid-grey-panel as though it were
|
||||
// white-on-near-black and would have waved real problems through.
|
||||
//
|
||||
// A gradient contributes one candidate per colour stop; translucent layers above
|
||||
// the opaque one are composited onto each candidate in order.
|
||||
backgroundCandidates(el) {
|
||||
const translucent = []; // top-down, above the opaque layer
|
||||
let base = null; // candidate colours for the first opaque layer
|
||||
|
||||
for (let node = el; node && !base; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
const stops = this.gradientStops(style.backgroundImage);
|
||||
const colour = this.parseColour(style.backgroundColor);
|
||||
|
||||
// A gradient whose stops are all opaque hides everything behind it.
|
||||
if (stops.length > 0 && stops.every(s => s.a === 1)) {
|
||||
base = stops;
|
||||
break;
|
||||
}
|
||||
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
|
||||
|
||||
if (colour && colour.a === 1) { base = [colour]; break; }
|
||||
if (colour && colour.a > 0) translucent.push(colour);
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
// Nothing opaque anywhere up the tree — fall back to the page's own colour
|
||||
// so a finding is still measured against something real.
|
||||
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
|
||||
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
|
||||
}
|
||||
|
||||
// Fold the translucent layers back on, innermost last so it lands on top.
|
||||
return base.map(candidate => {
|
||||
let composed = candidate;
|
||||
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
|
||||
return composed;
|
||||
});
|
||||
},
|
||||
|
||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||
const offenders = [];
|
||||
const unmeasured = [];
|
||||
|
||||
const nodes = selector
|
||||
? [...document.querySelectorAll(selector)]
|
||||
: [...document.querySelectorAll("*")];
|
||||
|
||||
nodes.forEach(el => {
|
||||
if (el.children.length > 0) return; // leaf text only
|
||||
const text = (el.textContent || "").trim();
|
||||
if (!text) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.visibility === "hidden" || style.display === "none") return;
|
||||
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
||||
// Decoration is exempt by definition: the watermark on the felt is meant
|
||||
// to be barely there, and holding it to a reading threshold would mean
|
||||
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
||||
// too, which is the same claim expressed where it also does some good.
|
||||
if (el.closest("[aria-hidden='true']")) return;
|
||||
|
||||
const fgRaw = this.parseColour(style.color);
|
||||
if (!fgRaw) return;
|
||||
|
||||
const candidates = this.backgroundCandidates(el);
|
||||
if (candidates.length === 0) {
|
||||
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// The kindest reading available: best contrast across every candidate.
|
||||
let best = 0;
|
||||
candidates.forEach(bg => {
|
||||
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||||
best = Math.max(best, this.contrastRatio(fg, bg));
|
||||
});
|
||||
|
||||
const sizePx = parseFloat(style.fontSize);
|
||||
const weight = parseInt(style.fontWeight, 10) || 400;
|
||||
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
|
||||
const required = isLarge ? largeMinimum : minimum;
|
||||
|
||||
if (best + 0.05 < required) {
|
||||
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
|
||||
}
|
||||
});
|
||||
|
||||
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
|
||||
},
|
||||
|
||||
// ---- Reach (touch target sizes) ----
|
||||
|
||||
// What a fingertip can actually hit, which is not the same as the element's box.
|
||||
//
|
||||
// Two things make the box misleading, and the first version of this check was
|
||||
// fooled by both. A control can be padded out with a transparent ::after, so it
|
||||
// responds well beyond its painted size — that's how a 22px dealer puck should
|
||||
// stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
|
||||
// tapping anywhere on the label, so a 24px box inside a 320x44 label is a
|
||||
// perfectly good target. Measuring the box reported both as faults.
|
||||
//
|
||||
// So it probes outwards with elementFromPoint until the point stops landing on the
|
||||
// control, which is the thing a finger actually experiences.
|
||||
effectiveTarget(el, limit = 48) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
// The probe walks outward a fixed distance, so it can only ever report
|
||||
// 2 x limit. Anything already drawn larger than that reads as exactly the cap
|
||||
// — which had every header button reporting a suspiciously round 80px. Give
|
||||
// the reach room for the element itself plus the padding being looked for.
|
||||
limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
|
||||
|
||||
// A wrapping or associated label forwards the tap, so it counts as the target.
|
||||
const label = el.closest("label") || (el.labels && el.labels[0]);
|
||||
const lands = (x, y) => {
|
||||
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit) return false;
|
||||
if (hit === el || el.contains(hit)) return true;
|
||||
return !!(label && (hit === label || label.contains(hit)));
|
||||
};
|
||||
|
||||
if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
|
||||
|
||||
const reach = (dx, dy) => {
|
||||
let far = 0;
|
||||
for (let d = 1; d <= limit; d++) {
|
||||
if (!lands(cx + dx * d, cy + dy * d)) break;
|
||||
far = d;
|
||||
}
|
||||
return far;
|
||||
};
|
||||
return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
|
||||
},
|
||||
|
||||
// The 44px guideline is about fingertips, so it only bites where there's no mouse.
|
||||
// Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
|
||||
// which is not a defect — the same buttons are 64px tall on a phone, where it
|
||||
// matters. Same reasoning as the field sizing: `hover: none` is what actually
|
||||
// distinguishes a finger from a pointer, not viewport width.
|
||||
//
|
||||
// `exempt` is for controls that are deliberately small *and* have a full-size path
|
||||
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
|
||||
// the rim of a seat, and growing it to 44px would take that area from the seat
|
||||
// itself — which a dealer taps far more often. "Give the button" is in the seat
|
||||
// menu at full size, so the puck is a shortcut, not the only way through.
|
||||
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
|
||||
const offenders = [];
|
||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
||||
}
|
||||
this.visibleControls()
|
||||
.filter(el => el.type !== "color")
|
||||
.filter(el => !(exempt && el.matches(exempt)))
|
||||
.forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Drawn big enough is big enough — no need to probe, and probing wide
|
||||
// elements is where the cap above used to produce nonsense. Seats sit
|
||||
// here: they're 76x72 and overlap each other at the rim by design, so
|
||||
// measuring their reach would report the crowding as a fault.
|
||||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
||||
|
||||
const size = this.effectiveTarget(el);
|
||||
if (!size) return;
|
||||
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||||
offenders.push(
|
||||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||
);
|
||||
});
|
||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
||||
},
|
||||
|
||||
// Tapping the middle of a seat must give you that seat.
|
||||
//
|
||||
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
|
||||
// outward from a chip that already hung over its seat's corner — and therefore
|
||||
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
|
||||
// handed the button to seat 9. The occlusion check missed it because it treated
|
||||
// anything inside a `.seat` as harmless crowding.
|
||||
//
|
||||
// Reported separately from occlusion because the cause is different: not an
|
||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||
seatOwnership() {
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
const offenders = [];
|
||||
|
||||
seats.forEach((seat, index) => {
|
||||
const rect = seat.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
|
||||
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit || seat.contains(hit) || hit === seat) return;
|
||||
|
||||
const other = hit.closest(".seat");
|
||||
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
|
||||
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
|
||||
offenders.push(
|
||||
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
|
||||
);
|
||||
});
|
||||
|
||||
return { name: "a seat's centre belongs to that seat", offenders };
|
||||
},
|
||||
|
||||
// A poker table has one dealer button. Nine pucks all showing "D", one of them
|
||||
// gold, turns the most-read marker on the felt into a spot-the-difference — and
|
||||
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
|
||||
// Both halves are checked here: what's drawn, and what it announces itself as.
|
||||
oneDealerButton() {
|
||||
const chips = [...document.querySelectorAll(".dealerChip")];
|
||||
const shown = chips.filter(chip => {
|
||||
const style = getComputedStyle(chip);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
if (parseFloat(style.opacity) <= 0.05) return false;
|
||||
// A hovered seat deliberately fades its empty slot in — that's the "put the
|
||||
// button here" preview, and it is on screen because a pointer is sitting on
|
||||
// that seat. Counting it reports two buttons whenever the mouse happens to
|
||||
// rest on the table, which is most of the time a mouse exists.
|
||||
const seat = chip.closest(".seat");
|
||||
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
|
||||
return true;
|
||||
});
|
||||
const offenders = [];
|
||||
|
||||
if (shown.length > 1) {
|
||||
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
|
||||
}
|
||||
|
||||
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
|
||||
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
|
||||
if (duplicated.length) {
|
||||
offenders.push(
|
||||
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { name: "the felt shows one dealer button", offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
runAll(options = {}) {
|
||||
const checks = [
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
this.occlusion(options.occlusionSelector),
|
||||
this.collapsedColumns(),
|
||||
this.textSpill(),
|
||||
this.horizontalOverflow(),
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
];
|
||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
||||
|
||||
return {
|
||||
viewport: `${innerWidth}x${innerHeight}`,
|
||||
failed: checks.filter(c => c.offenders.length > 0),
|
||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||
warnings: checks.flatMap(c => c.warnings || []),
|
||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
||||
clean: checks.every(c => c.offenders.length === 0),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = LayoutAudit;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Whether putting more in is called a bet or a raise.
|
||||
//
|
||||
// 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.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
// Walks the preflop action round to the big blind, who is owed nothing and gets to
|
||||
// choose between checking and putting more in.
|
||||
function toTheBigBlindsOption() {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
|
||||
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
}
|
||||
return { state, bigBlind };
|
||||
}
|
||||
|
||||
test("the big blind's option is a raise, not a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
|
||||
assert.equal(state.actingSeatIndex, bigBlind);
|
||||
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
|
||||
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
|
||||
});
|
||||
|
||||
test("and the minimum there is a real raise, not a call of the blind", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
|
||||
});
|
||||
|
||||
test("facing the blinds preflop is a raise", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("first in on the flop is a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "flop");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
|
||||
});
|
||||
|
||||
test("after that bet it's a raise again", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("and a bet again on the next street once it's checked around", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "turn");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("a table playing without blinds opens with a bet", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.currentBet, 0);
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
const before = JSON.stringify(state);
|
||||
Betting.wagerVerb(state);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
Reference in New Issue
Block a user