Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3866bdb335 | ||
|
|
d01c67fc45 | ||
|
|
a39675f975 | ||
|
|
870dee4a29 | ||
|
|
bcfc1f2f52 | ||
|
|
d168fedf1f | ||
|
|
68e263f325 | ||
|
|
105a74bc51 | ||
|
|
dce7e0f688 | ||
|
|
3851786b90 | ||
|
|
8e7d94e6f9 | ||
|
|
25bf67062a | ||
|
|
5d71572df6 | ||
|
|
98f95df8fb | ||
|
|
b7c9227bb8 | ||
|
|
ddb309f6db | ||
|
|
2db4048ed2 | ||
|
|
2f53c2f0ed | ||
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 | ||
|
|
f956f4bffa | ||
|
|
a53b0c2e43 | ||
|
|
edc4d70678 | ||
|
|
3bfcbaa190 | ||
|
|
59b318b75c | ||
|
|
e5e0c9cc48 | ||
|
|
3947bd0daf | ||
|
|
b99525ae51 | ||
|
|
9da7b1986b | ||
|
|
a0b8c2602a | ||
|
|
7604798e68 | ||
|
|
02163a4fc4 | ||
|
|
1680d13b05 | ||
|
|
a1e2ad9c2d | ||
|
|
26b4c0e59b | ||
|
|
aeef320cb4 | ||
|
|
d71e485611 | ||
|
|
d09a067377 | ||
|
|
acbee436bb | ||
|
|
d452ee414b | ||
|
|
f09ca60315 | ||
|
|
743c395e11 | ||
|
|
62e146648c | ||
|
|
6402ef268c | ||
|
|
c92c46e6d4 | ||
|
|
e8a3bfe432 | ||
|
|
8b78fd589c | ||
|
|
7bfbae7301 | ||
|
|
9b69489c4e |
@@ -0,0 +1,97 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
# Gitea's runner images are lean, so the toolchain is installed below rather
|
||||
# than assumed. The glibc that libchess_engine.so links against comes from
|
||||
# this image, not from the runner host — keep it matched to the prod server.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install toolchain
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
cmake build-essential rsync openssh-client
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||
|
||||
- name: Build native chess engine (libchess_engine.so)
|
||||
run: |
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build
|
||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||
|
||||
# Publishing the project rather than the solution keeps chess_engine.vcxproj
|
||||
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
|
||||
- name: Publish
|
||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||
|
||||
- name: Verify payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f publish/Resources/libchess_engine.so \
|
||||
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
|
||||
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|
||||
|| { echo "stockfish is not executable"; exit 1; }
|
||||
|
||||
- name: Prepare SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||
- name: Rsync to server
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
TARGET_DIR: ${{ secrets.TARGET_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
rsync -az --delete --exclude 'chess-data' \
|
||||
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
||||
|
||||
- name: Reload and restart service
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
|
||||
"sudo systemctl daemon-reload \
|
||||
&& sudo systemctl restart '$SERVICE_NAME' \
|
||||
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
|
||||
@@ -54,6 +54,12 @@ jobs:
|
||||
name: site-publish
|
||||
path: publish
|
||||
|
||||
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||
# carries it to the server, where the service user can run it regardless of file owner.
|
||||
- name: Restore Stockfish executable bit
|
||||
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
@@ -63,7 +69,9 @@ jobs:
|
||||
|
||||
- name: Rsync to server
|
||||
run: |
|
||||
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||
|
||||
- name: Reload and restart service
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# Learned chess engine weights (runtime training output, not source)
|
||||
chess-data/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
@@ -15,58 +14,60 @@ public class ChessController(
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore,
|
||||
ISelfPlayCoordinator selfPlay,
|
||||
AutoTrainingSettings autoTraining,
|
||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Store of ongoing games.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new chess game and store it in-memory.
|
||||
/// </summary>
|
||||
[HttpGet("new")]
|
||||
[HttpGet("new/{difficulty}")]
|
||||
public ActionResult CreateGame(int difficulty = 20)
|
||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
Guid playerId = Guid.NewGuid();
|
||||
Guid computerId = Guid.NewGuid();
|
||||
var isWhite = Random.Shared.Next(2) == 0;
|
||||
var isWhite = color.ToLowerInvariant() switch
|
||||
{
|
||||
"white" => true,
|
||||
"black" => false,
|
||||
_ => Random.Shared.Next(2) == 0,
|
||||
};
|
||||
|
||||
gameState.Computer = engineFactory.Create(difficulty);
|
||||
var computer = engineFactory.Create(difficulty);
|
||||
|
||||
if (isWhite)
|
||||
{
|
||||
gameState.WhitePlayerId = playerId;
|
||||
gameState.BlackPlayerId = computerId;
|
||||
gameState.BlackComputer = computer;
|
||||
}
|
||||
else
|
||||
{
|
||||
gameState.WhitePlayerId = computerId;
|
||||
gameState.BlackPlayerId = playerId;
|
||||
gameState.WhiteComputer = computer;
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give user's browser time to connect to signalR and such.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
||||
await orchestrator.PlayAsync(gameState);
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -77,28 +78,52 @@ public class ChessController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a game the computer plays against itself and auto-play it move by move,
|
||||
/// broadcasting each move so it can be watched on the spectator page.
|
||||
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
||||
/// chosen independently; when the learned engine plays, the game also trains it.
|
||||
/// </summary>
|
||||
[HttpGet("watch/cpu")]
|
||||
[HttpGet("watch/cpu/{difficulty}")]
|
||||
public ActionResult CreateSelfPlayGame(int difficulty = 4)
|
||||
public ActionResult CreateSelfPlayGame(
|
||||
int difficulty = 4,
|
||||
string whiteEngine = "custom",
|
||||
string blackEngine = "custom",
|
||||
int? whiteSkill = null,
|
||||
int? blackSkill = null)
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
var config = new SelfPlayConfig(
|
||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.Computer = engineFactory.Create(difficulty);
|
||||
var (gameId, _) = selfPlay.StartGame(config);
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
StartSelfPlay(gameState);
|
||||
return Ok(new { GameId = gameId });
|
||||
}
|
||||
|
||||
return Ok(new { gameState.GameId });
|
||||
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"stockfish" => ChessEngineKind.Stockfish,
|
||||
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
||||
_ => ChessEngineKind.Custom
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||
/// </summary>
|
||||
[HttpGet("autotrain")]
|
||||
public ActionResult GetAutoTrain() =>
|
||||
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
|
||||
/// <summary>
|
||||
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||
/// </summary>
|
||||
[HttpPost("autotrain")]
|
||||
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||
{
|
||||
autoTraining.GameCount = count; // clamped inside the setter
|
||||
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -110,12 +135,12 @@ public class ChessController(
|
||||
public ActionResult JoinGame()
|
||||
{
|
||||
Console.WriteLine("joining game");
|
||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
||||
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||
|
||||
if (gameState == null)
|
||||
{
|
||||
gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
}
|
||||
|
||||
Guid playerId = Guid.NewGuid();
|
||||
@@ -133,7 +158,7 @@ public class ChessController(
|
||||
isWhite = false;
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -149,7 +174,7 @@ public class ChessController(
|
||||
[HttpGet("active")]
|
||||
public ActionResult GetActiveGames()
|
||||
{
|
||||
var activeGames = _games.Values
|
||||
var activeGames = gameStore.All
|
||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||
@@ -158,6 +183,8 @@ public class ChessController(
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||
BlackEngine = g.BlackEngineKind.ToString(),
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
@@ -167,52 +194,46 @@ public class ChessController(
|
||||
return Ok(activeGames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
||||
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
||||
/// </summary>
|
||||
[HttpGet("weights")]
|
||||
public ActionResult GetLearnedWeights()
|
||||
{
|
||||
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
||||
|
||||
var snapshot = weightsStore.Snapshot();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
||||
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
||||
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}")]
|
||||
public ActionResult GetGameState(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var response = new
|
||||
{
|
||||
gameState.GameId,
|
||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
gameState.IsThreefoldRepetition,
|
||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
Pieces = gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new {
|
||||
p.Id,
|
||||
p.Type,
|
||||
p.Color,
|
||||
p.Position.Row,
|
||||
p.Position.Col,
|
||||
p.HasMoved
|
||||
}),
|
||||
gameState.MoveHistory
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
return Ok(gameState.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make a move in the specified game.
|
||||
/// Make a move in the specified game.
|
||||
/// The test passes a JSON body with a MoveDto.
|
||||
/// </summary>
|
||||
[HttpPost("move")]
|
||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
||||
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||
{
|
||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
||||
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
// Check if player is authorized to move
|
||||
@@ -239,16 +260,28 @@ public class ChessController(
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
else if (gameState.IsVsComputer)
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
else
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||
var state = gameState.ToDto();
|
||||
|
||||
return Ok(result);
|
||||
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||
// game. The mover also receives this echo but drops it via the version guard,
|
||||
// since it already rendered the same state from this response.
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||
|
||||
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
|
||||
? gameState.WhiteComputer
|
||||
: gameState.BlackComputer;
|
||||
|
||||
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState));
|
||||
|
||||
return Ok(new { result, state });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -258,7 +291,7 @@ public class ChessController(
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||
{
|
||||
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
|
||||
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
@@ -276,7 +309,7 @@ public class ChessController(
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -287,7 +320,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
@@ -299,7 +332,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||
@@ -313,7 +346,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves")]
|
||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||
@@ -325,75 +358,4 @@ public class ChessController(
|
||||
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's
|
||||
/// move (which applies and broadcasts it) until the game ends or is removed.
|
||||
/// </summary>
|
||||
private void StartSelfPlay(GameState gameState)
|
||||
{
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give spectators a moment to join the SignalR group before the first move.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
|
||||
while (_games.ContainsKey(gameState.GameId)
|
||||
&& !gameState.IsCheckmate
|
||||
&& !gameState.IsStalemate
|
||||
&& !gameState.IsThreefoldRepetition
|
||||
&& !gameState.IsForfeited)
|
||||
{
|
||||
try
|
||||
{
|
||||
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(_selfPlayMoveDelay);
|
||||
}
|
||||
|
||||
// Leave the finished game in place briefly so spectators can see the result.
|
||||
if (_games.ContainsKey(gameState.GameId))
|
||||
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_gameRemovalCancellationTokens[id] = cts;
|
||||
|
||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cts.Token);
|
||||
|
||||
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
||||
await game.Computer.DisposeAsync();
|
||||
|
||||
_games.Remove(id, out _);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||
{
|
||||
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Hubs;
|
||||
|
||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||
}
|
||||
|
||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
||||
{
|
||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
||||
}
|
||||
|
||||
public async Task LeaveWebsocketGroup(string gameId)
|
||||
{
|
||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
@@ -59,7 +60,20 @@ public class GameState
|
||||
public bool IsVsComputer { get; set; } = false;
|
||||
public bool IsComputerVsComputer { get; set; } = false;
|
||||
|
||||
public IChessEngine? Computer { get; set; }
|
||||
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
||||
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
||||
public IChessEngine? WhiteComputer { get; set; }
|
||||
public IChessEngine? BlackComputer { get; set; }
|
||||
|
||||
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
||||
// are the learning engine, and lets spectators see who is playing.
|
||||
public ChessEngineKind WhiteEngineKind { get; set; }
|
||||
public ChessEngineKind BlackEngineKind { get; set; }
|
||||
|
||||
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
||||
// learned engine). The engine records each played position into it and applies the
|
||||
// result on game over.
|
||||
public nint Trainer { get; set; }
|
||||
|
||||
// optional: convenience
|
||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single piece as sent to the client. Captured pieces keep their original
|
||||
/// type and color; their position is meaningless and omitted.
|
||||
/// </summary>
|
||||
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||
|
||||
/// <summary>
|
||||
/// The full board state pushed to clients. The same shape is returned by the
|
||||
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||
/// client always renders from one authoritative payload instead of re-fetching.
|
||||
/// </summary>
|
||||
public record GameStateDto(
|
||||
Guid GameId,
|
||||
string CurrentPlayer,
|
||||
bool IsCheck,
|
||||
bool IsCheckmate,
|
||||
bool IsStalemate,
|
||||
bool IsThreefoldRepetition,
|
||||
string? EnPassantTarget,
|
||||
bool WhiteCanCastleKingside,
|
||||
bool WhiteCanCastleQueenside,
|
||||
bool BlackCanCastleKingside,
|
||||
bool BlackCanCastleQueenside,
|
||||
IReadOnlyList<ChessPieceDto> Pieces,
|
||||
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||
IReadOnlyList<string> MoveHistory,
|
||||
// Moves in standard algebraic notation, for the move-list panel.
|
||||
IReadOnlyList<string> SanHistory,
|
||||
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||
int Version);
|
||||
|
||||
public static class GameStateMapper
|
||||
{
|
||||
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||
gameState.GameId,
|
||||
gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
gameState.IsThreefoldRepetition,
|
||||
gameState.EnPassantTarget?.ToString(),
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row < 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.MoveHistory,
|
||||
gameState.SanHistory,
|
||||
gameState.MoveHistory.Count);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
|
||||
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
|
||||
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
|
||||
/// king safety).
|
||||
/// </summary>
|
||||
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
|
||||
@@ -0,0 +1,58 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.CasinoModel
|
||||
@{
|
||||
ViewData["Title"] = "Casino Games";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="casino-container">
|
||||
<h1 class="casino-header">Casino Games</h1>
|
||||
<p class="casino-subtitle">Tools for running table games without doing the math yourself</p>
|
||||
|
||||
<div class="games-grid">
|
||||
<a href="/casino/texasholdem" class="game-card">
|
||||
<div class="game-icon">🃏</div>
|
||||
<div class="game-title">Texas Hold'em</div>
|
||||
<div class="game-description">
|
||||
A dealer's assistant: seat players around the table, track blinds, chips and
|
||||
all-ins, then split main and side pots automatically.
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/casino/texasholdem/quicksplit" class="game-card">
|
||||
<div class="game-icon">➗</div>
|
||||
<div class="game-title">Quick Split</div>
|
||||
<div class="game-description">
|
||||
Chop a pot in seconds on your phone — no table setup, just the amount,
|
||||
how many players are splitting it, and the smallest chip in play.
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="game-card disabled">
|
||||
<div class="game-icon">🎲</div>
|
||||
<div class="game-title">Craps</div>
|
||||
<div class="game-description">Payout calculator for the table.</div>
|
||||
<div class="coming-soon">Coming soon...</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card disabled">
|
||||
<div class="game-icon">🎡</div>
|
||||
<div class="game-title">Roulette</div>
|
||||
<div class="game-description">Bet tracking and payout reference.</div>
|
||||
<div class="coming-soon">Coming soon...</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card disabled">
|
||||
<div class="game-icon">🂡</div>
|
||||
<div class="game-title">Blackjack</div>
|
||||
<div class="game-description">Multi-hand dealer tracker.</div>
|
||||
<div class="coming-soon">Coming soon...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/" class="home-button">← Back to Home</a>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class CasinoModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
|
||||
@{
|
||||
ViewData["Title"] = "Texas Hold'em Dealer";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="holdemApp">
|
||||
<header id="holdemHeader">
|
||||
<div id="holdemHeaderLeft">
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="tableWatermark">Texas Hold'em</div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="seatsLayer"></div>
|
||||
</div>
|
||||
<p id="tableHint" class="tableHint"></p>
|
||||
</section>
|
||||
|
||||
<aside id="holdemSidebar">
|
||||
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatEditorHeader">
|
||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
|
||||
</div>
|
||||
<div id="seatEditorBody">
|
||||
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="sidebarPanel" open>
|
||||
<summary>Table Setup</summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Blinds move</span>
|
||||
<select id="directionSelect">
|
||||
<option value="clockwise" selected>Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Dedicated dealer (not a seat)</span>
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Base pot amount ($)</span>
|
||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||
</label>
|
||||
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session Standings</summary>
|
||||
<div class="panelBody">
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
<span>Player</span>
|
||||
<span>In</span>
|
||||
<span>Won</span>
|
||||
<span>Net</span>
|
||||
</div>
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Hand History</summary>
|
||||
<div class="panelBody">
|
||||
<div id="handHistoryList"></div>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="seatTemplate">
|
||||
<div class="seat" draggable="true">
|
||||
<div class="seatChairNumber"></div>
|
||||
<button class="dealerChip" title="Make this seat the dealer button">D</button>
|
||||
<span class="blindBadge sbBadge">SB</span>
|
||||
<span class="blindBadge bbBadge">BB</span>
|
||||
<div class="seatPuckName"></div>
|
||||
<div class="seatPuckCards"></div>
|
||||
<div class="seatPuckTotal"></div>
|
||||
</div>
|
||||
</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">
|
||||
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
|
||||
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
|
||||
</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" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
|
||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="chipCountTemplate">
|
||||
<label class="chipCountRow">
|
||||
<span class="chipSwatch"></span>
|
||||
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template id="customFieldTemplate">
|
||||
<div class="customFieldRow">
|
||||
<input type="text" class="customFieldLabel" placeholder="Label" maxlength="20" />
|
||||
<input type="text" class="customFieldValue" placeholder="Value" maxlength="40" />
|
||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeFieldBtn">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="standingsRowTemplate">
|
||||
<div class="standingsRow">
|
||||
<span class="standingsName"></span>
|
||||
<span class="standingsIn"></span>
|
||||
<span class="standingsWon"></span>
|
||||
<span class="standingsNet"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="handHistoryRowTemplate">
|
||||
<div class="handHistoryRow">
|
||||
<div class="handHistoryRowHeader">
|
||||
<span class="handHistoryHandNumber"></span>
|
||||
<span class="handHistoryPot"></span>
|
||||
<span class="handHistoryTime"></span>
|
||||
</div>
|
||||
<div class="handHistoryPayouts"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="toastHost" aria-live="polite"></div>
|
||||
|
||||
<div id="confirmOverlay" hidden>
|
||||
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal">
|
||||
<div class="potWizardHeader">
|
||||
<h2>Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Casino
|
||||
{
|
||||
public class TexasHoldemModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
|
||||
@{
|
||||
ViewData["Title"] = "Quick Split";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div id="splitApp">
|
||||
<header id="splitHeader">
|
||||
<h1>Quick Split</h1>
|
||||
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
|
||||
</header>
|
||||
|
||||
<div id="splitResult" aria-live="polite">
|
||||
<div id="splitResultLabel">Each player gets</div>
|
||||
<div id="splitResultAmount">$0.00</div>
|
||||
<div id="splitOddNote" hidden></div>
|
||||
<div id="splitDustNote" hidden></div>
|
||||
</div>
|
||||
|
||||
<section class="splitField">
|
||||
<label for="potInput" class="splitLabel">Pot</label>
|
||||
<div class="potInputWrap">
|
||||
<span class="potCurrency">$</span>
|
||||
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
|
||||
placeholder="0" aria-label="Pot amount" />
|
||||
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||
</section>
|
||||
|
||||
<div id="splitActions">
|
||||
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
|
||||
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||
</div>
|
||||
|
||||
<p class="splitFootnote">
|
||||
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
|
||||
<a href="/casino/texasholdem">full table</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
|
||||
{
|
||||
public class QuickSplitModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
}
|
||||
|
||||
<div id="chessContainer">
|
||||
<div id="boardContainer">
|
||||
<section id="boardArea">
|
||||
<div class="playerBar" id="topPlayerBar">
|
||||
<span class="playerDot black"></span>
|
||||
<span class="playerName">Opponent</span>
|
||||
<div class="capturedTray" id="captured-top"></div>
|
||||
<span class="advantage" id="advantage-top"></span>
|
||||
</div>
|
||||
|
||||
<div id="chessBoard">
|
||||
<!-- Placeholder squares -->
|
||||
@for (int i = 0; i < 64; i++)
|
||||
@@ -14,18 +21,41 @@
|
||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="textContainer" class="sideContent">
|
||||
<h1>Chess</h1>
|
||||
<p>Click a button to start a game :)</p>
|
||||
</div>
|
||||
<div class="playerBar" id="bottomPlayerBar">
|
||||
<span class="playerDot white"></span>
|
||||
<span class="playerName">You</span>
|
||||
<div class="capturedTray" id="captured-bottom"></div>
|
||||
<span class="advantage" id="advantage-bottom"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
<aside id="gamePanel">
|
||||
<header class="panelHeader">
|
||||
<span class="panelLogo">♞</span>
|
||||
<h1>Chess</h1>
|
||||
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
||||
</header>
|
||||
|
||||
<div id="moveList">
|
||||
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
||||
</div>
|
||||
|
||||
<div id="statusLine">Start a game to play.</div>
|
||||
|
||||
<div id="panelButtons">
|
||||
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
|
||||
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
|
||||
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="menuBackdrop" onclick="closeMenu()"></div>
|
||||
|
||||
<div id="mobileBar">
|
||||
<span id="mobileStatus">Start a game to play.</span>
|
||||
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,19 +89,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="colorModal" style="display: none;">
|
||||
<p>Play as:</p>
|
||||
<div id="colorButtonContainer">
|
||||
<button onclick="selectColor('white')">White</button>
|
||||
<button onclick="selectColor('black')">Black</button>
|
||||
<button onclick="selectColor('random')">Random</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,43 @@
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<label for="cpuDifficulty">Difficulty</label>
|
||||
<select id="cpuDifficulty">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>White</legend>
|
||||
<select id="whiteEngine">
|
||||
<option value="custom">Custom (v1)</option>
|
||||
<option value="customLearned" selected>Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="whiteSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Black</legend>
|
||||
<select id="blackEngine">
|
||||
<option value="custom" selected>Custom (v1)</option>
|
||||
<option value="customLearned">Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="blackSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Auto-train games</legend>
|
||||
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||
</div>
|
||||
|
||||
<div id="gamesFeed"></div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WeightsModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Learned Weights";
|
||||
}
|
||||
|
||||
<div id="weightsHeader">
|
||||
<h1>Learned Piece-Square Weights</h1>
|
||||
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
|
||||
<div id="weightsControls">
|
||||
<div class="heatLegend">
|
||||
<span>low</span>
|
||||
<span class="legendBar"></span>
|
||||
<span>high</span>
|
||||
</div>
|
||||
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
|
||||
</div>
|
||||
<a id="backToWatch" href="/watch">← Watch / train</a>
|
||||
</div>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Feature weights</h2>
|
||||
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
|
||||
<div id="featureWeights" class="featurePanel"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Midgame tables</h2>
|
||||
<div id="weightsGridMg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Endgame tables</h2>
|
||||
<div id="weightsGridEg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/ChessScripts/Weights.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WeightsModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -23,12 +23,23 @@ builder.Services.AddSingleton<IChessService, ChessService>();
|
||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
|
||||
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||
builder.Services.AddHostedService<AutoTrainingService>();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||
/// ChessEngine:AutoTrain config flag.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingService(
|
||||
ISelfPlayCoordinator coordinator,
|
||||
AutoTrainingSettings settings,
|
||||
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||
{
|
||||
private const int LearnedSkill = 6;
|
||||
private const int StockfishSkill = 20;
|
||||
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var running = new List<Task>();
|
||||
int started = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
running.RemoveAll(t => t.IsCompleted);
|
||||
|
||||
int desired = settings.GameCount;
|
||||
bool startFailed = false;
|
||||
|
||||
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||
var config = started++ % 2 == 0
|
||||
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||
|
||||
try
|
||||
{
|
||||
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||
running.Add(completion);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||
// persistent failure doesn't spin a tight loop, then try again.
|
||||
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||
startFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (startFailed)
|
||||
await Task.Delay(_restartBackoff, stoppingToken);
|
||||
else if (running.Count > 0)
|
||||
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||
// count increase promptly).
|
||||
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||
else
|
||||
// Pool is empty (count is 0) — just poll for the count to change.
|
||||
await Task.Delay(_pollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingSettings
|
||||
{
|
||||
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||
public const int MaxGames = 16;
|
||||
|
||||
private int _gameCount;
|
||||
|
||||
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||
|
||||
/// <summary>
|
||||
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||
/// </summary>
|
||||
public int GameCount
|
||||
{
|
||||
get => Volatile.Read(ref _gameCount);
|
||||
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||
}
|
||||
|
||||
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<BlogService> _logger;
|
||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
|
||||
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
|
||||
|
||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
|
||||
_cache.TryGetValue(key, out var entry);
|
||||
|
||||
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
|
||||
return (T?)entry.Value;
|
||||
|
||||
var result = await factory();
|
||||
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
|
||||
if (result is not null)
|
||||
{
|
||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||
return result;
|
||||
}
|
||||
else if (entry is not null)
|
||||
|
||||
return entry is null ? null : ServeStale<T>(key, entry);
|
||||
}
|
||||
|
||||
// Stale entries are dropped once the grace window closes so that a dead API fails the same
|
||||
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
|
||||
// the posts themselves, which reads as a site bug rather than an outage.
|
||||
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
|
||||
{
|
||||
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
|
||||
{
|
||||
// API unreachable — serve stale cache
|
||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
||||
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||
return (T?)entry.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
_cache.TryRemove(key, out _);
|
||||
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations;
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom
|
||||
Custom,
|
||||
|
||||
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||
CustomLearned
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
@@ -16,17 +19,46 @@ public sealed class ChessEngineOptions
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the learned-weights file. Leave null to default to
|
||||
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
|
||||
/// production set this to a stable, service-writable location OUTSIDE the deploy
|
||||
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
|
||||
/// trained weights survive deploys and avoid deploy-user vs service-user permission
|
||||
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||
/// </summary>
|
||||
public string? WeightsPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true (and outside Development), a background service continuously plays the learned
|
||||
/// engine against Stockfish to train it. Set to false to stop auto-training without a
|
||||
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||
/// </summary>
|
||||
public bool AutoTrain { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||
/// </summary>
|
||||
public int AutoTrainGameCount { get; set; } = 2;
|
||||
}
|
||||
|
||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
|
||||
public sealed class ChessEngineFactory(
|
||||
IOptions<ChessEngineOptions> options,
|
||||
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
|
||||
public IChessEngine Create(int skill) => _kind switch
|
||||
public IChessEngine Create(int skill) => Create(skill, _kind);
|
||||
|
||||
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
|
||||
{
|
||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||
_ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.")
|
||||
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,23 +11,60 @@ namespace JoshHeaps.Net.Services.Implementations;
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IChessService chessService) : IComputerMoveOrchestrator
|
||||
IChessService chessService,
|
||||
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||
{
|
||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
|
||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
|
||||
{
|
||||
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
|
||||
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
|
||||
|
||||
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||
|
||||
var move = uci.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
return await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
public async Task PlayRandomMoveAsync(GameState state)
|
||||
{
|
||||
var options = chessService.GetAllLegalMoves(state);
|
||||
|
||||
// No legal moves means the game is already over; let the caller's loop detect it.
|
||||
if (options.Count == 0)
|
||||
return;
|
||||
|
||||
var (piece, moves) = options[Random.Shared.Next(options.Count)];
|
||||
var target = moves[Random.Shared.Next(moves.Count)];
|
||||
|
||||
var move = new MoveDto
|
||||
{
|
||||
GameId = state.GameId,
|
||||
PlayerId = CurrentPlayerId(state),
|
||||
PieceId = piece.Id,
|
||||
SourceRow = piece.Position.Row,
|
||||
SourceCol = piece.Position.Col,
|
||||
TargetRow = target.Row,
|
||||
TargetCol = target.Col,
|
||||
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
|
||||
};
|
||||
|
||||
await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
|
||||
{
|
||||
var result = chessService.MakeMove(state, move);
|
||||
|
||||
// Record the played position for training (no-op for non-training games).
|
||||
if (state.Trainer != nint.Zero)
|
||||
weightsStore.Record(state.Trainer, state.ToFen());
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||
|
||||
return (move, result);
|
||||
}
|
||||
|
||||
private static Guid CurrentPlayerId(GameState state) =>
|
||||
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ using System.Runtime.InteropServices;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>Which evaluation the native engine uses.</summary>
|
||||
public enum EngineVariant
|
||||
{
|
||||
/// <summary>The hand-crafted evaluation.</summary>
|
||||
Classic,
|
||||
|
||||
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
|
||||
Learned
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||
@@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20)
|
||||
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||
{
|
||||
Skill = skill;
|
||||
|
||||
var handle = NativeMethods.engine_create($"skill={skill}");
|
||||
// weights= must come last: the native side reads the path as the rest of the
|
||||
// string, which lets it contain ';' and spaces.
|
||||
var options = variant == EngineVariant.Learned
|
||||
? $"skill={skill};variant=learned;weights={weightsPath}"
|
||||
: $"skill={skill}";
|
||||
|
||||
var handle = NativeMethods.engine_create(options);
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||
@@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
/// <summary>
|
||||
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
||||
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
||||
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||
/// </summary>
|
||||
private static partial class NativeMethods
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
@@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
|
||||
// ---- Learned-weights / training ABI (see chess_engine.h) ----
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void learned_load(string path);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr trainer_create();
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_record(IntPtr trainer, string fen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_destroy(IntPtr trainer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Concurrent;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
|
||||
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
|
||||
/// </summary>
|
||||
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
|
||||
|
||||
public void Add(GameState game) => _games[game.GameId] = game;
|
||||
|
||||
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
|
||||
|
||||
public bool Contains(Guid id) => _games.ContainsKey(id);
|
||||
|
||||
public IReadOnlyCollection<GameState> All => [.. _games.Values];
|
||||
|
||||
public void ScheduleRemove(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_removalCts.TryRemove(id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_removalCts[id] = cts;
|
||||
|
||||
_removalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cts.Token);
|
||||
|
||||
if (_games.TryGetValue(id, out var game))
|
||||
{
|
||||
if (game.WhiteComputer is not null)
|
||||
await game.WhiteComputer.DisposeAsync();
|
||||
if (game.BlackComputer is not null)
|
||||
await game.BlackComputer.DisposeAsync();
|
||||
|
||||
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
|
||||
if (game.Trainer != nint.Zero)
|
||||
{
|
||||
weightsStore.DestroyTrainer(game.Trainer);
|
||||
game.Trainer = nint.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
_games.Remove(id, out _);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||
_removalCts.TryRemove(id, out _);
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
|
||||
/// On construction it points the native engine at the weights file; everything else delegates
|
||||
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
|
||||
/// </summary>
|
||||
public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
||||
{
|
||||
private const int Pieces = 6; // Pawn..King
|
||||
private const int Squares = 64;
|
||||
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
||||
|
||||
public string WeightsFilePath { get; }
|
||||
|
||||
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
|
||||
{
|
||||
// Prefer the configured path (production points this outside the deploy dir); fall
|
||||
// back to the content root for local dev.
|
||||
var configured = options.Value.WeightsPath;
|
||||
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
|
||||
: configured;
|
||||
|
||||
// A missing/unwritable/misconfigured path must not take down the whole app — the
|
||||
// learned engine just plays from a neutral table and can't persist training.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
|
||||
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
|
||||
"play from a neutral table and training will not persist. In production set " +
|
||||
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
|
||||
WeightsFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
|
||||
|
||||
public void Record(nint trainer, string fen) =>
|
||||
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
|
||||
|
||||
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
|
||||
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
|
||||
|
||||
public void DestroyTrainer(nint trainer) =>
|
||||
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
|
||||
|
||||
public LearnedWeightsSnapshot Snapshot()
|
||||
{
|
||||
const int total = Pieces * Squares * 2 + Features;
|
||||
var buffer = new int[total];
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (int* p = buffer)
|
||||
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
|
||||
}
|
||||
|
||||
var mg = new int[Pieces][];
|
||||
var eg = new int[Pieces][];
|
||||
|
||||
for (int piece = 0; piece < Pieces; piece++)
|
||||
{
|
||||
mg[piece] = new int[Squares];
|
||||
eg[piece] = new int[Squares];
|
||||
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
|
||||
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
|
||||
}
|
||||
|
||||
var features = new int[Features];
|
||||
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
|
||||
|
||||
return new LearnedWeightsSnapshot(mg, eg, features);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||
/// </summary>
|
||||
public sealed class SelfPlayCoordinator(
|
||||
IChessService chessService,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore) : ISelfPlayCoordinator
|
||||
{
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Plies of random legal moves at the start of a training game, so self-play and
|
||||
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||
private const int _openingRandomPlies = 4;
|
||||
|
||||
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||
IChessEngine blackComputer;
|
||||
try
|
||||
{
|
||||
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
throw;
|
||||
}
|
||||
|
||||
var gameState = chessService.CreateNewGame();
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.WhiteEngineKind = config.WhiteKind;
|
||||
gameState.BlackEngineKind = config.BlackKind;
|
||||
gameState.WhiteComputer = whiteComputer;
|
||||
gameState.BlackComputer = blackComputer;
|
||||
|
||||
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||
gameState.Trainer = weightsStore.CreateTrainer();
|
||||
|
||||
gameStore.Add(gameState);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||
return (gameState.GameId, completion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||
/// </summary>
|
||||
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
bool aborted = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Give spectators a moment to join the SignalR group before the first move.
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
|
||||
// Training games open with random moves so they don't replay the same line.
|
||||
if (gameState.Trainer != nint.Zero)
|
||||
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||
{
|
||||
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
|
||||
var lastMoveCount = gameState.MoveHistory.Count;
|
||||
var lastProgress = DateTime.UtcNow;
|
||||
|
||||
while (IsLive(gameState, cancellationToken))
|
||||
{
|
||||
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||
|
||||
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||
{
|
||||
lastMoveCount = gameState.MoveHistory.Count;
|
||||
lastProgress = DateTime.UtcNow;
|
||||
}
|
||||
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||
throw new TimeoutException("no move played within the idle timeout");
|
||||
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||
catch (TimeoutException)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||
}
|
||||
|
||||
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||
// idle past the timeout) records nothing and just frees its trainer.
|
||||
if (aborted)
|
||||
DiscardTraining(gameState);
|
||||
else
|
||||
ApplyLearning(gameState);
|
||||
|
||||
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||
if (gameStore.Contains(gameState.GameId))
|
||||
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||
/// observed so it isn't an unobserved task exception.
|
||||
/// </summary>
|
||||
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
var play = orchestrator.PlayAsync(gameState);
|
||||
try
|
||||
{
|
||||
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||
!cancellationToken.IsCancellationRequested
|
||||
&& gameStore.Contains(gameState.GameId)
|
||||
&& !IsGameOver(gameState);
|
||||
|
||||
private static bool IsGameOver(GameState gameState) =>
|
||||
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||
/// </summary>
|
||||
private void ApplyLearning(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||
/// idle-timed-out game teaches the model nothing.
|
||||
/// </summary>
|
||||
private void DiscardTraining(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||
/// </summary>
|
||||
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||
{
|
||||
winner = PieceColor.White;
|
||||
weight = 1.0;
|
||||
|
||||
if (gameState.IsCheckmate)
|
||||
{
|
||||
// The side to move is the mated one, so the winner is the other color.
|
||||
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||
{
|
||||
var (white, black) = MaterialCounts(gameState);
|
||||
|
||||
if (white == black)
|
||||
return false; // a balanced draw carries no signal
|
||||
|
||||
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||
weight = 0.5;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // forfeit / unfinished
|
||||
}
|
||||
|
||||
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||
{
|
||||
int white = 0, black = 0;
|
||||
|
||||
for (int row = 0; row < 8; row++)
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var piece = gameState.Board[row, col];
|
||||
|
||||
if (piece is null)
|
||||
continue;
|
||||
|
||||
int value = piece.Type switch
|
||||
{
|
||||
PieceType.Pawn => 1,
|
||||
PieceType.Knight => 3,
|
||||
PieceType.Bishop => 3,
|
||||
PieceType.Rook => 5,
|
||||
PieceType.Queen => 9,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
if (piece.Color == PieceColor.White)
|
||||
white += value;
|
||||
else
|
||||
black += value;
|
||||
}
|
||||
|
||||
return (white, black);
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
|
||||
if (line == token) break;
|
||||
}
|
||||
|
||||
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
|
||||
// here forever — that would block GameStore's removal task and leave the finished game
|
||||
// stuck in the store, cluttering the watch page indefinitely.
|
||||
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Send("quit");
|
||||
_stdin.Close();
|
||||
await _p.WaitForExitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (!_p.HasExited)
|
||||
_p.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
_p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces;
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game. The caller owns and disposes it.
|
||||
/// Creates a new engine instance for a single game using the configured default engine.
|
||||
/// The caller owns and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game using an explicitly chosen engine
|
||||
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
|
||||
/// and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <param name="kind">The engine implementation to create.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill, ChessEngineKind kind);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,24 @@ using JoshHeaps.Net.Models;
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer move: asks the engine for a move, applies it through the rules
|
||||
/// service, and broadcasts the result to the game's clients.
|
||||
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
|
||||
/// the rules service, and broadcasts the result to the game's clients.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the engine pick a move for the current position, applies it, and broadcasts it.
|
||||
/// Has the side-to-move's engine pick a move for the current position, applies it, and
|
||||
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a move in.</param>
|
||||
/// <param name="engine">The engine that selects the move.</param>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
|
||||
|
||||
/// <summary>
|
||||
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
|
||||
/// broadcasting it. Used to randomize the opening of training games so self-play and
|
||||
/// engine-vs-engine games don't replay the same line every time.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a random move in.</param>
|
||||
Task PlayRandomMoveAsync(GameState state);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
|
||||
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
|
||||
/// auto-training games), so every game is reachable from one place for lookup and spectating.
|
||||
/// </summary>
|
||||
public interface IGameStore
|
||||
{
|
||||
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||
void Add(GameState game);
|
||||
|
||||
/// <summary>Look a game up by id.</summary>
|
||||
bool TryGet(Guid id, out GameState game);
|
||||
|
||||
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||
bool Contains(Guid id);
|
||||
|
||||
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||
IReadOnlyCollection<GameState> All { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||
/// </summary>
|
||||
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Thin managed facade over the native learned-weights model. The weights, all feature
|
||||
/// computation, per-game accumulation, the update rule, and persistence live in the native
|
||||
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
|
||||
/// the table back for visualization.
|
||||
/// </summary>
|
||||
public interface ILearnedWeightsStore
|
||||
{
|
||||
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
|
||||
string WeightsFilePath { get; }
|
||||
|
||||
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
|
||||
LearnedWeightsSnapshot Snapshot();
|
||||
|
||||
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
|
||||
nint CreateTrainer();
|
||||
|
||||
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
|
||||
void Record(nint trainer, string fen);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a finished game's outcome to the global weights (and saves): rewards the
|
||||
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
|
||||
/// </summary>
|
||||
void ApplyResult(nint trainer, PieceColor winner, double weight);
|
||||
|
||||
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
|
||||
void DestroyTrainer(nint trainer);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||
public sealed record SelfPlayConfig(
|
||||
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||
ChessEngineKind BlackKind, int BlackSkill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
|
||||
/// the learned engine plays) feeding the result back into the learned weights. Used by the
|
||||
/// spectator "watch" endpoint and by the auto-trainer.
|
||||
/// </summary>
|
||||
public interface ISelfPlayCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Create, register, and start running a self-play game. Returns immediately with the new
|
||||
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
|
||||
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
|
||||
/// </summary>
|
||||
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.casino-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.casino-header {
|
||||
color: #f5f5f5;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.casino-subtitle {
|
||||
color: #a0a0a0;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 3rem auto;
|
||||
max-width: 900px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 2rem 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
background: #2f2f2f;
|
||||
border-color: #4a4a4a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.game-card.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-card.disabled:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #3a3a3a;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.game-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
color: #f5f5f5;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.home-button {
|
||||
margin-top: 4rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
background: transparent;
|
||||
color: #f5f5f5;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.home-button:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.casino-header {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||
|
||||
body {
|
||||
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
color: #e6e6e6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#splitApp {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
|
||||
#splitHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.04em;
|
||||
color: #f5f5f5;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
#splitHeader h1::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #d8b46a, #a9782f);
|
||||
}
|
||||
|
||||
.splitSubtitle {
|
||||
margin: 0.35rem 0 1.25rem;
|
||||
color: #8f8f8f;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- The answer ---------- */
|
||||
|
||||
#splitResult {
|
||||
background: linear-gradient(160deg, #1f2a21, #161c17);
|
||||
border: 1px solid #2c3a2f;
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem 1.1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
#splitResult.hasAmount {
|
||||
border-color: #3c6b46;
|
||||
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#splitResultLabel {
|
||||
color: #8f9d92;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#splitResultAmount {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(2.6rem, 14vw, 3.4rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
color: #f2f2f2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
#splitResult.hasAmount #splitResultAmount {
|
||||
color: #7ee2a0;
|
||||
}
|
||||
|
||||
#splitOddNote {
|
||||
margin-top: 0.7rem;
|
||||
color: #d8b46a;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#splitDustNote {
|
||||
margin-top: 0.5rem;
|
||||
color: #e0a1a1;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
.splitField {
|
||||
display: block;
|
||||
margin-bottom: 1.35rem;
|
||||
}
|
||||
|
||||
.splitLabel {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.potInputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.75rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.potInputWrap:focus-within {
|
||||
border-color: #d8b46a;
|
||||
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
|
||||
}
|
||||
|
||||
.potCurrency {
|
||||
color: #777;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
#potInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #f2f2f2;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.55rem 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#potInput::placeholder {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.clearPotBtn {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #262626;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearPotBtn:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* ---------- Tap targets ---------- */
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.choiceChip {
|
||||
flex: 1 1 auto;
|
||||
min-width: 58px;
|
||||
min-height: 52px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 11px;
|
||||
color: #ddd;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.choiceChip:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.choiceChip.selected {
|
||||
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||
border-color: #d8b46a;
|
||||
color: #f0d9a8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.waysStepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 150px;
|
||||
background: #191919;
|
||||
border: 1px solid #333;
|
||||
border-radius: 11px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.stepBtn {
|
||||
width: 40px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #262626;
|
||||
color: #ddd;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepBtn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.waysReadout {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- Footer actions ---------- */
|
||||
|
||||
#splitActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.splitBtn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #ddd;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.splitBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
|
||||
.splitBtnGhost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.splitFootnote {
|
||||
margin-top: 1.5rem;
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.splitFootnote a {
|
||||
color: #a9782f;
|
||||
}
|
||||
|
||||
/* Roomier once there's a real screen to work with. */
|
||||
@media (min-width: 700px) {
|
||||
#splitApp {
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
#splitHeader h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
#boardContainer {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 2vw auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
#chessBoard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
@@ -165,6 +157,47 @@
|
||||
pointer-events: none; /* ensures img doesn't steal the click */
|
||||
}
|
||||
|
||||
#colorModal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #1e1e1e;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#colorModal p {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#colorButtonContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#colorButtonContainer button {
|
||||
background-color: #2c2c2c;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#colorButtonContainer button:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.chessSquare .coordinate-label {
|
||||
position: absolute;
|
||||
font-size: 1.2vw;
|
||||
|
||||
@@ -1,159 +1,429 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
cursor: default;
|
||||
:root {
|
||||
--bg: #2b2c30;
|
||||
--panel: #21232a;
|
||||
--panel-row: rgba(255, 255, 255, 0.035);
|
||||
--bar: #1e2026;
|
||||
--accent: #8cd5ed;
|
||||
--accent-ink: #10222a;
|
||||
--text: #d6d6d6;
|
||||
--muted: #8b8f99;
|
||||
--line: #34373f;
|
||||
--bar-h: clamp(34px, 5.5vh, 50px);
|
||||
--radius: 12px;
|
||||
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
/* The chess page is a fixed, single-screen app: never scroll. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
}
|
||||
|
||||
#copyPgnBtn {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
}
|
||||
|
||||
#watchLink {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
margin: 2vh;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#watchLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* ---- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Portrait / narrow: board on top, panel stacked below. */
|
||||
#chessContainer {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 5vw;
|
||||
gap: clamp(8px, 1.6vh, 16px);
|
||||
padding: clamp(10px, 2vh, 20px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
width: min(94vw, 56vh);
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 90vw; /* use the smaller of width or height */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 90vw;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
align-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
#gamePanel {
|
||||
width: min(94vw, 56vh);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
flex-direction: column;
|
||||
order: -1;
|
||||
flex-shrink: 1;
|
||||
font-size: large;
|
||||
margin: 5vw;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
flex-grow: 3;
|
||||
}
|
||||
|
||||
/* Put UI to left/right when screen is short */
|
||||
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||
@media (min-aspect-ratio: 1/1) {
|
||||
#chessContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto, auto;
|
||||
grid-template-rows: auto, auto;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
gap: clamp(16px, 3vw, 48px);
|
||||
padding: clamp(12px, 3vh, 28px);
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: min(5vw, 5vh);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
padding: 1vw;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
padding: 1vw;
|
||||
margin: 1vw;
|
||||
font-size: large;
|
||||
#boardArea {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 1;
|
||||
width: min(90vh, 90vw);
|
||||
max-height: 90vh;
|
||||
width: min(72vh, 54vw);
|
||||
height: min(72vh, 54vw);
|
||||
}
|
||||
|
||||
#difficultyButtonContainer {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
.playerBar {
|
||||
width: min(72vh, 54vw);
|
||||
}
|
||||
}
|
||||
|
||||
#gamePanel {
|
||||
width: clamp(300px, 26vw, 380px);
|
||||
height: min(86vh, 100%);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Player bars ------------------------------------------------------- */
|
||||
|
||||
.playerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: var(--bar-h);
|
||||
padding: 0 12px;
|
||||
background: var(--bar);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.playerDot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerDot.white {
|
||||
background: #ededed;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
|
||||
}
|
||||
|
||||
.playerDot.black {
|
||||
background: #2c2c2c;
|
||||
box-shadow: 0 0 0 1px #565656 inset;
|
||||
}
|
||||
|
||||
.playerName {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capturedTray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capturedPiece {
|
||||
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
|
||||
width: auto;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
.advantage {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Game panel -------------------------------------------------------- */
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelLogo {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panelHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#moveList {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.movePlaceholder {
|
||||
margin: 0;
|
||||
padding: 18px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.moveRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4em 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.moveRow:nth-child(odd) {
|
||||
background: var(--panel-row);
|
||||
}
|
||||
|
||||
.moveNum {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.moveSan {
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.moveSan.latest {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#statusLine {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#statusLine.alert {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#panelButtons {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: clamp(0.92rem, 1vw, 1.05rem);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: clamp(10px, 1.4vh, 14px) 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #363a44;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #424752;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: #2a2e37;
|
||||
}
|
||||
|
||||
#watchLink {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#watchLink:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
|
||||
|
||||
.iconBtn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
|
||||
#mobileBar,
|
||||
#menuBackdrop,
|
||||
#menuClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Phone layout ------------------------------------------------------ */
|
||||
/* The full panel doesn't fit alongside a usable board on a phone, so we show
|
||||
the board + a slim status/menu bar, and tuck the move list and secondary
|
||||
actions into a slide-up sheet. The page itself still never scrolls. */
|
||||
@media (max-width: 640px) {
|
||||
#chessContainer {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerBar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
|
||||
#gamePanel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 85dvh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
z-index: 60;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body.menu-open #gamePanel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The slim bar already shows status, so hide the sheet's inline copy. */
|
||||
#gamePanel #statusLine {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
body.menu-open #menuBackdrop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mobileBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#mobileStatus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#mobileStatus.alert {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#mobileBar #menuToggle {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,27 @@ html, body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#cpuDifficulty {
|
||||
.enginePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
.enginePicker legend {
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
#watchControls select {
|
||||
background-color: #1e1e1e;
|
||||
color: #d6d6d6;
|
||||
border: 1px solid #444;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #141414;
|
||||
color: #d6d6d6;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#weightsHeader {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
#weightsHeader h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
#weightsStatus {
|
||||
color: #9a9a9a;
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
#weightsControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.heatLegend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.legendBar {
|
||||
width: 120px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
|
||||
}
|
||||
|
||||
#refreshWeights {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#refreshWeights:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToWatch {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.weightsSection {
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.weightsSection h2 {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
text-align: center;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.weightsGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.featurePanel {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.featureRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.featureLabel {
|
||||
flex: 0 0 6.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #c8c8c8;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.featureTrack {
|
||||
flex: 1 1 auto;
|
||||
height: 14px;
|
||||
background-color: #232323;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featureBar {
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.featureValue {
|
||||
flex: 0 0 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #e8e8e8;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weightBoard {
|
||||
background-color: #1c1c1c;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.weightBoardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weightBoardIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.weightRange {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.miniHeat {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1rem repeat(8, 34px);
|
||||
}
|
||||
|
||||
.heatSquare {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
color: #f0f0f0;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.heatLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
font-size: 0.6rem;
|
||||
color: #777;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createCPUGame(difficulty) {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||
async createCPUGame(difficulty, color = "random") {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
@@ -46,13 +46,15 @@ const ChessAPI = {
|
||||
throw new Error(message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// The move endpoint returns both the move result and the full resulting
|
||||
// board state, so the caller can render without a follow-up fetch.
|
||||
const data = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Invalid move or not your turn.");
|
||||
if (!data.result.success) {
|
||||
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
return result;
|
||||
return data;
|
||||
},
|
||||
|
||||
async handleMove(targetRow, targetCol) {
|
||||
@@ -85,16 +87,14 @@ const ChessAPI = {
|
||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||
|
||||
try {
|
||||
const moveResult = await this.makeMove(moveDto);
|
||||
const { result, state } = await this.makeMove(moveDto);
|
||||
|
||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||
ChessBoard.renderPieces(updatedGame.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
GameState.clearSelection();
|
||||
ChessInteractions.clearHighlights();
|
||||
|
||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||
this.alertGameStatusChange(moveResult);
|
||||
this.alertGameStatusChange(result);
|
||||
|
||||
} catch (error) {
|
||||
alert("❌ " + error.message);
|
||||
|
||||
@@ -1,4 +1,91 @@
|
||||
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
||||
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
||||
|
||||
const ChessBoard = {
|
||||
// Render from a full game-state payload (the shape returned by the move/state
|
||||
// endpoints and pushed over SignalR). Ignores state older than what's already
|
||||
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
||||
renderState(state) {
|
||||
if (!state || !GameState.shouldApply(state.version)) return;
|
||||
this.renderPieces(state.pieces);
|
||||
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
||||
this.renderMoveList(state.sanHistory);
|
||||
this.renderStatus(state);
|
||||
GameState.setVersion(state.version);
|
||||
},
|
||||
|
||||
// Two-column numbered move list (white move, black move), latest highlighted.
|
||||
renderMoveList(sanHistory) {
|
||||
const list = document.getElementById("moveList");
|
||||
if (!list) return;
|
||||
|
||||
const san = sanHistory ?? [];
|
||||
|
||||
if (san.length === 0) {
|
||||
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
for (let i = 0; i < san.length; i += 2) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "moveRow";
|
||||
|
||||
const num = document.createElement("span");
|
||||
num.className = "moveNum";
|
||||
num.textContent = `${i / 2 + 1}.`;
|
||||
row.appendChild(num);
|
||||
|
||||
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
||||
|
||||
if (i + 1 < san.length)
|
||||
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
||||
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
},
|
||||
|
||||
moveCell(san, isLatest) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
||||
cell.textContent = san;
|
||||
return cell;
|
||||
},
|
||||
|
||||
renderStatus(state) {
|
||||
let text;
|
||||
let alert = false;
|
||||
|
||||
if (state.isCheckmate) {
|
||||
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
||||
text = `Checkmate — ${winner} wins`;
|
||||
alert = true;
|
||||
} else if (state.isStalemate) {
|
||||
text = "Draw — stalemate";
|
||||
alert = true;
|
||||
} else if (state.isThreefoldRepetition) {
|
||||
text = "Draw — threefold repetition";
|
||||
alert = true;
|
||||
} else {
|
||||
text = `${state.currentPlayer} to move`;
|
||||
if (state.isCheck) {
|
||||
text += " — check";
|
||||
alert = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror to both the desktop panel status and the mobile bar status.
|
||||
["statusLine", "mobileStatus"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle("alert", alert);
|
||||
});
|
||||
},
|
||||
|
||||
renderPieces(pieces) {
|
||||
this.clearAllSquares();
|
||||
this.renderCoordinateLabels();
|
||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
||||
this.highlightPreviousMove();
|
||||
},
|
||||
|
||||
// Show each side's captured pieces and the leading side's material advantage,
|
||||
// arranged so the current player's tray sits below the board.
|
||||
renderCapturedTrays(activePieces, capturedPieces) {
|
||||
const captured = capturedPieces ?? [];
|
||||
|
||||
// A captured piece's color is the side that lost it, so White's haul is the
|
||||
// captured Black pieces, and vice versa.
|
||||
const whiteCaptured = captured.filter(p => p.color === 1);
|
||||
const blackCaptured = captured.filter(p => p.color === 0);
|
||||
|
||||
// Net material from pieces still on the board, so promotions count correctly.
|
||||
const advantage = (activePieces ?? []).reduce(
|
||||
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
||||
|
||||
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
||||
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
||||
|
||||
const isWhite = GameState.currentPlayerIsWhite !== false;
|
||||
this.fillCapturedTray("bottom", isWhite ? white : black);
|
||||
this.fillCapturedTray("top", isWhite ? black : white);
|
||||
},
|
||||
|
||||
fillCapturedTray(position, side) {
|
||||
const tray = document.getElementById(`captured-${position}`);
|
||||
const badge = document.getElementById(`advantage-${position}`);
|
||||
if (!tray || !badge) return;
|
||||
|
||||
tray.innerHTML = "";
|
||||
[...side.captured]
|
||||
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
||||
.forEach(piece => {
|
||||
const img = document.createElement("img");
|
||||
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "capturedPiece";
|
||||
img.draggable = false;
|
||||
tray.appendChild(img);
|
||||
});
|
||||
|
||||
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
||||
},
|
||||
|
||||
clearAllSquares() {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`square-${i}`);
|
||||
|
||||
@@ -23,6 +23,16 @@ const ChessModals = {
|
||||
resolve(difficulty);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
promptColor() {
|
||||
return new Promise(resolve => {
|
||||
document.getElementById("colorModal").style.display = "block";
|
||||
window.selectColor = (color) => {
|
||||
document.getElementById("colorModal").style.display = "none";
|
||||
resolve(color);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ const ChessSignalR = {
|
||||
console.error("❌ SignalR connection closed:", err?.message);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||
@@ -28,12 +28,14 @@ const ChessSignalR = {
|
||||
}
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameId);
|
||||
// Drop the echo of our own move and any out-of-order delivery.
|
||||
if (!GameState.shouldApply(state?.version)) return;
|
||||
|
||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||
},
|
||||
@@ -50,12 +52,6 @@ const ChessSignalR = {
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
}
|
||||
},
|
||||
|
||||
async leaveGame() {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||
|
||||
@@ -6,6 +6,10 @@ const GameState = {
|
||||
legalMoves: [],
|
||||
previousMoveStart: null,
|
||||
previousMoveEnd: null,
|
||||
// Highest ply (move count) already rendered. Lets us ignore stale or
|
||||
// already-applied updates that arrive out of order, including the echo
|
||||
// of our own move.
|
||||
lastVersion: -1,
|
||||
|
||||
reset() {
|
||||
this.currentGameId = null;
|
||||
@@ -15,12 +19,22 @@ const GameState = {
|
||||
this.legalMoves = [];
|
||||
this.previousMoveStart = null;
|
||||
this.previousMoveEnd = null;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
shouldApply(version) {
|
||||
return typeof version !== "number" || version > this.lastVersion;
|
||||
},
|
||||
|
||||
setVersion(version) {
|
||||
if (typeof version === "number") this.lastVersion = version;
|
||||
},
|
||||
|
||||
setGameInfo(gameId, playerId, isWhite) {
|
||||
this.currentGameId = gameId;
|
||||
this.currentPlayerId = playerId;
|
||||
this.currentPlayerIsWhite = isWhite;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
setSelectedPiece(piece) {
|
||||
|
||||
@@ -11,8 +11,8 @@ const Spectate = {
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
||||
this.handleMoveUpdate(gameId, moveDto));
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||
this.handleMoveUpdate(gameId, moveDto, state));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
@@ -23,14 +23,50 @@ const Spectate = {
|
||||
}
|
||||
|
||||
await this.refreshGames();
|
||||
await this.loadAutoTrainCount();
|
||||
setInterval(() => this.refreshGames(), 5000);
|
||||
},
|
||||
|
||||
async startCpuGame() {
|
||||
const difficulty = document.getElementById("cpuDifficulty").value;
|
||||
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||
async loadAutoTrainCount() {
|
||||
const input = document.getElementById("autoTrainCount");
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/chess/watch/cpu/${difficulty}`);
|
||||
const response = await fetch("/api/chess/autotrain");
|
||||
const data = await response.json();
|
||||
input.max = data.max;
|
||||
// Don't clobber the value while the user is editing it.
|
||||
if (document.activeElement !== input)
|
||||
input.value = data.count;
|
||||
} catch {
|
||||
// Leave the control as-is if auto-training status can't be read.
|
||||
}
|
||||
},
|
||||
|
||||
async setAutoTrainCount() {
|
||||
const input = document.getElementById("autoTrainCount");
|
||||
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
||||
const data = await response.json();
|
||||
input.value = data.count;
|
||||
} catch (err) {
|
||||
console.error("❌ Could not set the auto-training game count.", err);
|
||||
}
|
||||
},
|
||||
|
||||
async startCpuGame() {
|
||||
const params = new URLSearchParams({
|
||||
whiteEngine: document.getElementById("whiteEngine").value,
|
||||
whiteSkill: document.getElementById("whiteSkill").value,
|
||||
blackEngine: document.getElementById("blackEngine").value,
|
||||
blackSkill: document.getElementById("blackSkill").value
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch(`/api/chess/watch/cpu?${params}`);
|
||||
await this.refreshGames();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||
@@ -68,7 +104,9 @@ const Spectate = {
|
||||
async addGame(game) {
|
||||
this.games.set(game.gameId, {
|
||||
isVsComputer: game.isVsComputer,
|
||||
isComputerVsComputer: game.isComputerVsComputer
|
||||
isComputerVsComputer: game.isComputerVsComputer,
|
||||
whiteEngine: game.whiteEngine,
|
||||
blackEngine: game.blackEngine
|
||||
});
|
||||
|
||||
const card = document.createElement("div");
|
||||
@@ -117,7 +155,10 @@ const Spectate = {
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const state = await response.json();
|
||||
this.renderFromState(gameId, await response.json());
|
||||
},
|
||||
|
||||
renderFromState(gameId, state) {
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
@@ -128,10 +169,13 @@ const Spectate = {
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, state) {
|
||||
if (!this.games.has(gameId)) return;
|
||||
|
||||
await this.renderGame(gameId);
|
||||
// Render from the pushed state; fall back to a fetch only if it's missing.
|
||||
if (state) this.renderFromState(gameId, state);
|
||||
else await this.renderGame(gameId);
|
||||
|
||||
this.highlightMove(gameId, moveDto);
|
||||
},
|
||||
|
||||
@@ -177,11 +221,21 @@ const Spectate = {
|
||||
},
|
||||
|
||||
gameLabel(stored) {
|
||||
if (stored.isComputerVsComputer) return "CPU vs CPU";
|
||||
if (stored.isComputerVsComputer)
|
||||
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
||||
if (stored.isVsComputer) return "Vs CPU";
|
||||
return "Player vs Player";
|
||||
},
|
||||
|
||||
engineName(kind) {
|
||||
switch (kind) {
|
||||
case "CustomLearned": return "Learned";
|
||||
case "Custom": return "Custom";
|
||||
case "Stockfish": return "Stockfish";
|
||||
default: return kind || "CPU";
|
||||
}
|
||||
},
|
||||
|
||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
||||
if (stored.result)
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
const Weights = {
|
||||
async init() {
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
let data;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/weights");
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not load weights.", err);
|
||||
document.getElementById("weightsStatus").textContent = "Could not load weights.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderBoardSet(data.mg, "weightsGridMg");
|
||||
this.renderBoardSet(data.eg, "weightsGridEg");
|
||||
this.renderFeatures(data.features);
|
||||
|
||||
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|
||||
|| data.features.some(f => f.value !== 0);
|
||||
|
||||
document.getElementById("weightsStatus").textContent = trained
|
||||
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
|
||||
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
|
||||
},
|
||||
|
||||
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
|
||||
renderBoardSet(pieces, containerId) {
|
||||
const overall = new Array(64).fill(0);
|
||||
for (const piece of pieces)
|
||||
for (let sq = 0; sq < 64; sq++)
|
||||
overall[sq] += piece.squares[sq];
|
||||
|
||||
const boards = [{ name: "Overall", squares: overall }, ...pieces];
|
||||
const grid = document.getElementById(containerId);
|
||||
grid.innerHTML = "";
|
||||
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
|
||||
},
|
||||
|
||||
buildBoard(board) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "weightBoard";
|
||||
|
||||
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "weightBoardHeader";
|
||||
if (board.name !== "Overall") {
|
||||
const icon = document.createElement("img");
|
||||
icon.src = `/images/Chess Images/White${board.name}.svg`;
|
||||
icon.alt = board.name;
|
||||
icon.className = "weightBoardIcon";
|
||||
header.appendChild(icon);
|
||||
}
|
||||
const title = document.createElement("span");
|
||||
title.textContent = board.name;
|
||||
header.appendChild(title);
|
||||
const range = document.createElement("span");
|
||||
range.className = "weightRange";
|
||||
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
|
||||
header.appendChild(range);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const heat = document.createElement("div");
|
||||
heat.className = "miniHeat";
|
||||
|
||||
for (let row = 0; row < 8; row++) {
|
||||
const rankLabel = document.createElement("div");
|
||||
rankLabel.className = "heatLabel";
|
||||
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
|
||||
heat.appendChild(rankLabel);
|
||||
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const rank = 7 - row; // rank index, 0 = rank 1
|
||||
const sq = rank * 8 + col; // white-relative square (A1 = 0)
|
||||
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
|
||||
}
|
||||
}
|
||||
|
||||
heat.appendChild(this.cornerSpacer());
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const fileLabel = document.createElement("div");
|
||||
fileLabel.className = "heatLabel";
|
||||
fileLabel.textContent = String.fromCharCode(97 + col);
|
||||
heat.appendChild(fileLabel);
|
||||
}
|
||||
|
||||
wrapper.appendChild(heat);
|
||||
return wrapper;
|
||||
},
|
||||
|
||||
buildSquare(value, sq, maxAbs) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "heatSquare";
|
||||
|
||||
if (value !== 0 && maxAbs > 0) {
|
||||
const ratio = Math.abs(value) / maxAbs;
|
||||
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
|
||||
cell.style.backgroundColor = value > 0
|
||||
? `rgba(74, 134, 232, ${alpha})` // high -> blue
|
||||
: `rgba(232, 74, 74, ${alpha})`; // low -> red
|
||||
cell.textContent = value;
|
||||
}
|
||||
|
||||
const file = String.fromCharCode(97 + (sq & 7));
|
||||
const rank = (sq >> 3) + 1;
|
||||
cell.title = `${file}${rank}: ${value}`;
|
||||
return cell;
|
||||
},
|
||||
|
||||
cornerSpacer() {
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "heatLabel";
|
||||
return spacer;
|
||||
},
|
||||
|
||||
// features: [{ name, value }]
|
||||
renderFeatures(features) {
|
||||
const panel = document.getElementById("featureWeights");
|
||||
panel.innerHTML = "";
|
||||
|
||||
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
|
||||
|
||||
features.forEach(feature => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "featureRow";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "featureLabel";
|
||||
label.textContent = feature.name;
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "featureTrack";
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "featureBar";
|
||||
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
|
||||
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
||||
bar.style.backgroundColor = feature.value >= 0
|
||||
? "rgba(74, 134, 232, 0.85)"
|
||||
: "rgba(232, 74, 74, 0.85)";
|
||||
track.appendChild(bar);
|
||||
|
||||
const value = document.createElement("span");
|
||||
value.className = "featureValue";
|
||||
value.textContent = feature.value;
|
||||
|
||||
row.append(label, track, value);
|
||||
panel.appendChild(row);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => Weights.init());
|
||||
|
||||
console.log("Weights.js loaded");
|
||||
@@ -30,6 +30,18 @@ function resetCopyPgn() {
|
||||
window.copyPgn = copyPgn;
|
||||
window.showCopyPgn = showCopyPgn;
|
||||
|
||||
// Mobile slide-up menu (move list + secondary actions).
|
||||
function toggleMenu() {
|
||||
document.body.classList.toggle("menu-open");
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
document.body.classList.remove("menu-open");
|
||||
}
|
||||
|
||||
window.toggleMenu = toggleMenu;
|
||||
window.closeMenu = closeMenu;
|
||||
|
||||
async function forfeitCurrentGame() {
|
||||
const gameId = ChessUtils.getCookie("chessGameId");
|
||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||
@@ -44,6 +56,7 @@ async function forfeitCurrentGame() {
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
@@ -63,7 +76,7 @@ async function startNewGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start new game:", error);
|
||||
@@ -71,13 +84,15 @@ async function startNewGame() {
|
||||
}
|
||||
|
||||
async function startCPUGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const difficulty = await ChessModals.promptDifficulty();
|
||||
const gameData = await ChessAPI.createCPUGame(difficulty);
|
||||
const color = await ChessModals.promptColor();
|
||||
const gameData = await ChessAPI.createCPUGame(difficulty, color);
|
||||
|
||||
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||
GameState.clearPreviousMove();
|
||||
@@ -91,7 +106,7 @@ async function startCPUGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start CPU game:", error);
|
||||
@@ -111,7 +126,7 @@ async function resumeSavedGame() {
|
||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||
|
||||
await ChessSignalR.setupConnection();
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (err) {
|
||||
console.warn("Saved game not found or expired.", err);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Toasts and confirmation dialogs, so the table never has to fall back on the
|
||||
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
|
||||
// the rest of the page, and on a phone it's a full-screen interruption — all of
|
||||
// which is a bad trade for a dealer trying to keep a game moving.
|
||||
|
||||
// A short-lived message at the bottom of the screen, optionally with one action
|
||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||
const Toast = {
|
||||
DEFAULT_DURATION_MS: 8000,
|
||||
timer: null,
|
||||
|
||||
show(message, options = {}) {
|
||||
const host = document.getElementById("toastHost");
|
||||
if (!host) return;
|
||||
|
||||
this.dismiss();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.setAttribute("role", "status");
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "toastText";
|
||||
text.textContent = message;
|
||||
toast.appendChild(text);
|
||||
|
||||
if (options.actionLabel && options.onAction) {
|
||||
const action = document.createElement("button");
|
||||
action.type = "button";
|
||||
action.className = "toastAction";
|
||||
action.textContent = options.actionLabel;
|
||||
action.addEventListener("click", () => {
|
||||
this.dismiss();
|
||||
options.onAction();
|
||||
});
|
||||
toast.appendChild(action);
|
||||
}
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toastClose";
|
||||
close.setAttribute("aria-label", "Dismiss");
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", () => this.dismiss());
|
||||
toast.appendChild(close);
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
options.duration || this.DEFAULT_DURATION_MS
|
||||
);
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
|
||||
const Dialog = {
|
||||
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.getElementById("confirmOverlay");
|
||||
const modal = overlay.querySelector("#confirmModal");
|
||||
overlay.querySelector("#confirmTitle").textContent = title;
|
||||
overlay.querySelector("#confirmMessage").textContent = message;
|
||||
|
||||
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
|
||||
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
cancelBtn.textContent = cancelLabel;
|
||||
confirmBtn.classList.toggle("holdemBtnDanger", danger);
|
||||
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
|
||||
|
||||
const finish = result => {
|
||||
overlay.hidden = true;
|
||||
confirmBtn.removeEventListener("click", onAccept);
|
||||
cancelBtn.removeEventListener("click", onCancel);
|
||||
overlay.removeEventListener("click", onBackdrop);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
resolve(result);
|
||||
};
|
||||
const onAccept = () => finish(true);
|
||||
const onCancel = () => finish(false);
|
||||
const onBackdrop = e => {
|
||||
if (e.target === overlay) finish(false);
|
||||
};
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") finish(false);
|
||||
if (e.key === "Enter") finish(true);
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener("click", onAccept);
|
||||
cancelBtn.addEventListener("click", onCancel);
|
||||
overlay.addEventListener("click", onBackdrop);
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
overlay.hidden = false;
|
||||
modal.focus();
|
||||
confirmBtn.focus();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Toast, Dialog };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Turns the in-memory hand history into a CSV file the dealer can download for a
|
||||
// paper trail — a house manager or a disputed chip count needs something that
|
||||
// outlives the browser tab, not just a scrollable panel in the sidebar.
|
||||
const HandHistoryExport = {
|
||||
csvField(value) {
|
||||
const str = String(value ?? "");
|
||||
return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
|
||||
},
|
||||
|
||||
formatTimestamp(timestamp) {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
toCsv(handHistory, standings = []) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
[...handHistory].reverse().forEach(entry => {
|
||||
const payouts = (entry.payouts || [])
|
||||
.map(p => `${p.label}: ${Money.format(p.amount)}`)
|
||||
.join("; ");
|
||||
rows.push([
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.format(entry.pot),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
|
||||
// The settle-up numbers go in the same file as the hands they came from, so a
|
||||
// dealer handing over one export isn't asked for a second one at cash-out.
|
||||
if (standings.length > 0) {
|
||||
rows.push([]);
|
||||
rows.push(["Session Standings"]);
|
||||
rows.push(["Player", "Paid In", "Won", "Net"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||
},
|
||||
|
||||
fileName() {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
||||
return `holdem-hand-history-${stamp}.csv`;
|
||||
},
|
||||
|
||||
download(handHistory, standings = []) {
|
||||
const csv = this.toCsv(handHistory, standings);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = this.fileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandHistoryExport;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
const hooks = {
|
||||
save: s => HoldemState.save(s),
|
||||
rerender: () => Render.renderAll(),
|
||||
};
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||
// just happens, and the toast offers the way back for the few seconds anyone
|
||||
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||
// undo is for the mistake you just made, not one from last night's session.
|
||||
const runUndoable = (message, mutate) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
mutate();
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", e => {
|
||||
HoldemState.resizeSeats(state, Number(e.target.value));
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("directionSelect").addEventListener("change", e => {
|
||||
state.direction = e.target.value;
|
||||
hooks.save(state);
|
||||
Render.renderSeats();
|
||||
});
|
||||
|
||||
document.getElementById("basePotInput").addEventListener("input", e => {
|
||||
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("dedicatedDealerToggle").addEventListener("change", e => {
|
||||
state.dedicatedDealer = e.target.checked;
|
||||
hooks.save(state);
|
||||
Render.renderSeats();
|
||||
Render.renderTableHint();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
state.chipLegend.push({
|
||||
id: HoldemState.makeId("chip"),
|
||||
color: "#888888",
|
||||
value: 1,
|
||||
});
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", () => {
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
|
||||
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
|
||||
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());
|
||||
Render.mount(state, hooks);
|
||||
Toast.show("Table reset.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||
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);
|
||||
HoldemState.startNextHand(state);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
||||
});
|
||||
|
||||
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Clear the hand history?",
|
||||
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
|
||||
confirmLabel: "Clear history",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
HoldemState.clearHistory(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// Shared money formatting for every casino surface. Kept apart from the table's
|
||||
// rendering so the quick-split calculator can pull in the formatter without dragging
|
||||
// the whole seat/chip UI along with it.
|
||||
const Money = {
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
if (value > 0) return `+${this.format(value)}`;
|
||||
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
||||
return this.format(0);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Money;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Pure side-pot math. No DOM, no globals besides the PotCalculator namespace, so the
|
||||
// dealer's payout logic can be reasoned about (and unit tested) on its own.
|
||||
//
|
||||
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
|
||||
// that player put in the pot and is whittled down round by round as money gets paid out.
|
||||
const PotCalculator = {
|
||||
EPSILON: 0.004,
|
||||
|
||||
// Builds the initial entry list from raw contributions, keeping folded players in
|
||||
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
|
||||
// who put in nothing at all.
|
||||
buildEntries(players) {
|
||||
return players
|
||||
.filter(p => p.contribution > this.EPSILON)
|
||||
.map(p => ({ id: p.id, remaining: p.contribution, folded: !!p.folded }));
|
||||
},
|
||||
|
||||
totalPot(entries) {
|
||||
return entries.reduce((sum, e) => sum + e.remaining, 0);
|
||||
},
|
||||
|
||||
// Players still eligible to be picked as a winner for the money that's left.
|
||||
getContenders(entries) {
|
||||
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
|
||||
},
|
||||
|
||||
// Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
|
||||
// The payout is capped at the smallest remaining contribution among the winners —
|
||||
// that's the classic side-pot rule: an all-in player can only win what everyone at
|
||||
// the table matched, and the excess rolls forward to the next round.
|
||||
resolveRound(entries, winnerIds) {
|
||||
const winners = entries.filter(e => winnerIds.includes(e.id) && e.remaining > this.EPSILON);
|
||||
if (winners.length === 0) {
|
||||
throw new Error("No valid winners selected for this round.");
|
||||
}
|
||||
|
||||
const cap = Math.min(...winners.map(w => w.remaining));
|
||||
let payoutPool = 0;
|
||||
const updatedEntries = entries.map(e => {
|
||||
if (e.remaining <= this.EPSILON) return e;
|
||||
const used = Math.min(e.remaining, cap);
|
||||
payoutPool += used;
|
||||
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
|
||||
});
|
||||
|
||||
payoutPool = Math.round(payoutPool * 100) / 100;
|
||||
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
|
||||
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
|
||||
|
||||
const perWinner = winnerIds
|
||||
.filter(id => winners.some(w => w.id === id))
|
||||
.map(id => ({ id, amountCents: baseShareCents }));
|
||||
|
||||
// Odd cents (can't split evenly) go one at a time to the winners, in the order
|
||||
// they were selected, so nothing is ever lost to rounding.
|
||||
let i = 0;
|
||||
while (leftoverCents > 0 && perWinner.length > 0) {
|
||||
perWinner[i % perWinner.length].amountCents += 1;
|
||||
leftoverCents--;
|
||||
i++;
|
||||
}
|
||||
|
||||
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
|
||||
const remainingContenders = this.getContenders(updatedEntries);
|
||||
|
||||
return {
|
||||
payoutPool,
|
||||
capApplied: cap,
|
||||
perWinner: result,
|
||||
updatedEntries,
|
||||
remainingContenders,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = PotCalculator;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Walks the dealer through awarding the pot: "who won?", and if that player was
|
||||
// all-in for less than everyone else, "who's next?" — repeating until every dollar
|
||||
// in the pot has been assigned. Built on PotCalculator's pure round-resolution math.
|
||||
const PotWizard = {
|
||||
state: null,
|
||||
onFinished: null,
|
||||
entries: [],
|
||||
playerLabelById: new Map(),
|
||||
log: [],
|
||||
|
||||
open(state, onFinished) {
|
||||
this.state = state;
|
||||
this.onFinished = onFinished;
|
||||
|
||||
const players = state.seats.filter(
|
||||
p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0
|
||||
);
|
||||
this.playerLabelById = new Map(
|
||||
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
|
||||
);
|
||||
this.entries = PotCalculator.buildEntries(
|
||||
players.map(p => ({
|
||||
id: p.id,
|
||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
folded: p.folded,
|
||||
}))
|
||||
);
|
||||
this.log = [];
|
||||
this.totals = new Map();
|
||||
|
||||
document.getElementById("potWizardOverlay").hidden = false;
|
||||
this.renderStep();
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("potWizardOverlay").hidden = true;
|
||||
},
|
||||
|
||||
labelFor(player, seatIndex) {
|
||||
const name = HoldemState.displayName(player, seatIndex);
|
||||
const cards = (player.cards || []).filter(Boolean).join(" ");
|
||||
return cards ? `${name} (${cards})` : name;
|
||||
},
|
||||
|
||||
addToTotal(id, amount) {
|
||||
this.totals.set(id, (this.totals.get(id) || 0) + amount);
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
const result = PotCalculator.resolveRound(this.entries, [contenders[0].id]);
|
||||
this.recordRound(result);
|
||||
this.entries = result.updatedEntries;
|
||||
contenders = PotCalculator.getContenders(this.entries);
|
||||
}
|
||||
|
||||
const body = document.getElementById("potWizardBody");
|
||||
body.innerHTML = "";
|
||||
body.appendChild(this.renderLog());
|
||||
|
||||
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
|
||||
|
||||
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
|
||||
const warning = document.createElement("p");
|
||||
warning.className = "potWizardPrompt";
|
||||
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
|
||||
body.appendChild(warning);
|
||||
body.appendChild(this.renderSummary());
|
||||
return;
|
||||
}
|
||||
|
||||
if (contenders.length === 0 || remaining <= PotCalculator.EPSILON) {
|
||||
body.appendChild(this.renderSummary());
|
||||
return;
|
||||
}
|
||||
|
||||
const round = document.createElement("div");
|
||||
round.className = "potWizardRound";
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
round.appendChild(prompt);
|
||||
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "potWizardChoices";
|
||||
contenders.forEach(c => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "potWizardChoice";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = c.id;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(text);
|
||||
choices.appendChild(label);
|
||||
});
|
||||
round.appendChild(choices);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "fieldHint";
|
||||
hint.textContent = "Check more than one to split the pot on a tie.";
|
||||
round.appendChild(hint);
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
||||
confirmBtn.textContent = "Confirm Winner(s)";
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
||||
if (selected.length === 0) return;
|
||||
const result = PotCalculator.resolveRound(this.entries, selected);
|
||||
this.recordRound(result);
|
||||
this.entries = result.updatedEntries;
|
||||
this.renderStep();
|
||||
});
|
||||
round.appendChild(confirmBtn);
|
||||
|
||||
body.appendChild(round);
|
||||
},
|
||||
|
||||
recordRound(result) {
|
||||
result.perWinner.forEach(w => this.addToTotal(w.id, w.amount));
|
||||
this.log.push({
|
||||
amount: result.payoutPool,
|
||||
winners: result.perWinner.map(w => `${this.playerLabelById.get(w.id)} (${Money.format(w.amount)})`),
|
||||
});
|
||||
},
|
||||
|
||||
renderLog() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardLog";
|
||||
if (this.log.length === 0) return wrap;
|
||||
|
||||
this.log.forEach((entry, idx) => {
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardLogLine";
|
||||
line.textContent = `Pot ${idx + 1}: ${Money.format(entry.amount)} → ${entry.winners.join(", ")}`;
|
||||
wrap.appendChild(line);
|
||||
});
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderSummary() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardSummary";
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "Final Payouts";
|
||||
wrap.appendChild(heading);
|
||||
|
||||
if (this.totals.size === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "fieldHint";
|
||||
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("ul");
|
||||
for (const [id, amount] of this.totals.entries()) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(list);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "potWizardActions";
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||
applyBtn.textContent = "Start Next Hand";
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
this.close();
|
||||
this.onFinished(payouts);
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "holdemBtn holdemBtnGhost";
|
||||
closeBtn.textContent = "Close";
|
||||
closeBtn.addEventListener("click", () => this.close());
|
||||
actions.appendChild(closeBtn);
|
||||
|
||||
wrap.appendChild(actions);
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = PotWizard;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
|
||||
// A dealer opens this one-handed with players waiting, so every control is a tap and
|
||||
// the answer updates as they type rather than behind a "calculate" button.
|
||||
const QuickSplit = {
|
||||
WAY_CHOICES: [2, 3, 4, 5],
|
||||
CHIP_CHOICES: [
|
||||
{ value: 0.01, label: "1¢" },
|
||||
{ value: 0.25, label: "25¢" },
|
||||
{ value: 1, label: "$1" },
|
||||
{ value: 5, label: "$5" },
|
||||
{ value: 25, label: "$25" },
|
||||
],
|
||||
MAX_WAYS: 12,
|
||||
|
||||
ways: 2,
|
||||
chip: 1,
|
||||
|
||||
mount() {
|
||||
this.potInput = document.getElementById("potInput");
|
||||
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||
|
||||
this.renderWayChoices();
|
||||
this.renderChipChoices();
|
||||
|
||||
this.potInput.addEventListener("input", () => {
|
||||
this.sanitizePotInput();
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.clearPotBtn.addEventListener("click", () => {
|
||||
this.potInput.value = "";
|
||||
this.potInput.focus();
|
||||
this.update();
|
||||
});
|
||||
|
||||
document.getElementById("resetSplitBtn").addEventListener("click", () => {
|
||||
this.potInput.value = "";
|
||||
this.ways = 2;
|
||||
this.chip = 1;
|
||||
this.renderWayChoices();
|
||||
this.renderChipChoices();
|
||||
this.potInput.focus();
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
// Keeps the field to something parseable without fighting the typist: digits and a
|
||||
// single decimal point, at most two places.
|
||||
sanitizePotInput() {
|
||||
const cleaned = this.potInput.value
|
||||
.replace(/[^0-9.]/g, "")
|
||||
.replace(/(\..*)\./g, "$1")
|
||||
.replace(/(\.\d\d)\d+/, "$1");
|
||||
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
|
||||
},
|
||||
|
||||
makeChoiceButton(label, isSelected, onPick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "choiceChip";
|
||||
btn.textContent = label;
|
||||
btn.setAttribute("aria-pressed", String(isSelected));
|
||||
btn.classList.toggle("selected", isSelected);
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
renderWayChoices() {
|
||||
const row = document.getElementById("waysChoices");
|
||||
row.innerHTML = "";
|
||||
|
||||
this.WAY_CHOICES.forEach(count => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||
this.ways = count;
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||
// spend a row of buttons on it.
|
||||
const more = document.createElement("div");
|
||||
more.className = "waysStepper";
|
||||
|
||||
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = `${this.ways} ways`;
|
||||
|
||||
more.appendChild(minus);
|
||||
more.appendChild(readout);
|
||||
more.appendChild(plus);
|
||||
row.appendChild(more);
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "stepBtn";
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
stepWays(delta) {
|
||||
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
},
|
||||
|
||||
renderChipChoices() {
|
||||
const row = document.getElementById("chipChoices");
|
||||
row.innerHTML = "";
|
||||
this.CHIP_CHOICES.forEach(choice => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
|
||||
this.chip = choice.value;
|
||||
this.renderChipChoices();
|
||||
this.update();
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
update() {
|
||||
const potValue = Number(this.potInput.value);
|
||||
const pot = Number.isNaN(potValue) ? 0 : potValue;
|
||||
this.clearPotBtn.hidden = this.potInput.value === "";
|
||||
|
||||
const result = SplitCalculator.split(pot, this.ways, this.chip);
|
||||
|
||||
document.getElementById("splitResultLabel").textContent =
|
||||
`Each of ${result.players} players gets`;
|
||||
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
|
||||
|
||||
const oddNote = document.getElementById("splitOddNote");
|
||||
if (result.valid && result.oddChips > 0) {
|
||||
oddNote.textContent = result.oddChips === 1
|
||||
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||
`first ${result.oddChips} players left of the button`;
|
||||
oddNote.hidden = false;
|
||||
} else {
|
||||
oddNote.hidden = true;
|
||||
}
|
||||
|
||||
const dustNote = document.getElementById("splitDustNote");
|
||||
if (result.valid && result.dust > 0) {
|
||||
dustNote.textContent =
|
||||
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||
`pick a smaller denomination.`;
|
||||
dustNote.hidden = false;
|
||||
} else {
|
||||
dustNote.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
@@ -0,0 +1,433 @@
|
||||
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
|
||||
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
|
||||
// re-draws everything (used after structural changes like resizing seats or the chip
|
||||
// legend).
|
||||
//
|
||||
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
|
||||
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
|
||||
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
|
||||
// full form in the seat editor panel instead.
|
||||
const Render = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
selectedSeatIndex: null,
|
||||
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.selectedSeatIndex = null;
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
renderAll() {
|
||||
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
|
||||
this.selectedSeatIndex = null;
|
||||
}
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.renderChipLegend();
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
renderTableHint() {
|
||||
const hint = document.getElementById("tableHint");
|
||||
hint.textContent = this.state.dedicatedDealer
|
||||
? "Drag a seat onto another seat to swap players."
|
||||
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
const notesEl = document.getElementById("tableNotes");
|
||||
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
|
||||
},
|
||||
|
||||
updatePotBadge() {
|
||||
const total = HoldemState.totalPot(this.state);
|
||||
document.getElementById("potBadgeAmount").textContent = Money.format(total);
|
||||
},
|
||||
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
|
||||
seatPosition(index, count) {
|
||||
const angle = -90 + (360 / count) * index;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const rx = 44;
|
||||
const ry = 42;
|
||||
const left = 50 + rx * Math.cos(radians);
|
||||
const top = 50 + ry * Math.sin(radians);
|
||||
return { left, top };
|
||||
},
|
||||
|
||||
puckLabel(player, seatIndex) {
|
||||
return HoldemState.displayName(player, seatIndex);
|
||||
},
|
||||
|
||||
renderSeats() {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
layer.innerHTML = "";
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
const pos = this.seatPosition(index, this.state.seatCount);
|
||||
node.style.left = `${pos.left}%`;
|
||||
node.style.top = `${pos.top}%`;
|
||||
node.dataset.seatIndex = String(index);
|
||||
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
dealerChip.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
this.state.dealerSeatIndex = index;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
});
|
||||
|
||||
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
|
||||
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
|
||||
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
node.addEventListener("click", () => {
|
||||
// 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.
|
||||
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
});
|
||||
|
||||
this.wireDragAndDrop(node, index);
|
||||
|
||||
layer.appendChild(node);
|
||||
});
|
||||
},
|
||||
|
||||
isRedSuit(card) {
|
||||
return /[HD]$/i.test(card || "");
|
||||
},
|
||||
|
||||
updatePuckContent(node, player, index) {
|
||||
node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : "";
|
||||
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 = card;
|
||||
cardsEl.appendChild(span);
|
||||
});
|
||||
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||
node.classList.toggle("seatInactive", !player.inHand);
|
||||
node.classList.toggle("seatFolded", player.folded);
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
// (which live in a separate panel) never lose focus.
|
||||
refreshPuck(index) {
|
||||
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||||
},
|
||||
|
||||
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 card1 = form.querySelector(".card1Input");
|
||||
const card2 = form.querySelector(".card2Input");
|
||||
card1.value = player.cards[0] || "";
|
||||
card2.value = player.cards[1] || "";
|
||||
[card1, card2].forEach((input, cardIdx) => {
|
||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
||||
input.addEventListener("input", () => {
|
||||
input.value = input.value.toUpperCase();
|
||||
input.classList.toggle("cardRed", this.isRedSuit(input.value));
|
||||
player.cards[cardIdx] = input.value;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
});
|
||||
|
||||
const amountInput = form.querySelector(".amountOverrideInput");
|
||||
amountInput.value = player.amountOverride ?? "";
|
||||
amountInput.addEventListener("input", () => {
|
||||
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const addFieldBtn = form.querySelector(".addFieldBtn");
|
||||
addFieldBtn.addEventListener("click", () => {
|
||||
player.customFields.push({ label: "", value: "" });
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
},
|
||||
|
||||
renderChipCounts(form, player, index) {
|
||||
const container = form.querySelector(".chipCounts");
|
||||
container.innerHTML = "";
|
||||
const template = document.getElementById("chipCountTemplate");
|
||||
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||||
row.title = `$${chip.value} chips`;
|
||||
const input = row.querySelector(".chipCountInput");
|
||||
input.value = player.chipCounts[chip.id] || 0;
|
||||
input.addEventListener("input", () => {
|
||||
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
|
||||
player.chipCounts[chip.id] = count;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
renderCustomFields(form, player, index) {
|
||||
const container = form.querySelector(".customFields");
|
||||
container.innerHTML = "";
|
||||
const template = document.getElementById("customFieldTemplate");
|
||||
|
||||
player.customFields.forEach((field, fieldIdx) => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const labelInput = row.querySelector(".customFieldLabel");
|
||||
const valueInput = row.querySelector(".customFieldValue");
|
||||
labelInput.value = field.label;
|
||||
valueInput.value = field.value;
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
field.value = valueInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Chip legend ----
|
||||
|
||||
renderChipLegend() {
|
||||
const list = document.getElementById("chipLegendList");
|
||||
list.innerHTML = "";
|
||||
const template = document.getElementById("chipLegendRowTemplate");
|
||||
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const colorInput = row.querySelector(".chipColorInput");
|
||||
const valueInput = row.querySelector(".chipValueInput");
|
||||
colorInput.value = chip.color;
|
||||
valueInput.value = chip.value;
|
||||
|
||||
colorInput.addEventListener("input", () => {
|
||||
chip.color = colorInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
chip.value = Number(valueInput.value) || 0;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.updatePotBadge();
|
||||
});
|
||||
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
|
||||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
||||
this.hooks.save(this.state);
|
||||
this.renderAll();
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Session standings ----
|
||||
|
||||
renderStandings() {
|
||||
const list = document.getElementById("standingsList");
|
||||
const emptyHint = document.getElementById("standingsEmptyHint");
|
||||
const table = document.getElementById("standingsTable");
|
||||
const standings = HoldemState.sessionStandings(this.state);
|
||||
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = standings.length > 0;
|
||||
table.hidden = standings.length === 0;
|
||||
|
||||
const template = document.getElementById("standingsRowTemplate");
|
||||
standings.forEach(row => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".standingsName").textContent = row.name;
|
||||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||||
|
||||
const netEl = node.querySelector(".standingsNet");
|
||||
netEl.textContent = Money.formatSigned(row.net);
|
||||
netEl.classList.toggle("netUp", row.net > 0);
|
||||
netEl.classList.toggle("netDown", row.net < 0);
|
||||
|
||||
list.appendChild(node);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
formatHandTime(timestamp) {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
renderHandHistory() {
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
||||
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
||||
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
|
||||
|
||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Render };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Pure math for chopping one pot between tied players. No DOM.
|
||||
//
|
||||
// A dealer can't push half a chip across the felt, so this works in whole chips of
|
||||
// whatever the smallest denomination in play is: everyone gets the same stack, and
|
||||
// whatever won't divide is called out separately as odd chips. House rule for those
|
||||
// is that they go to the first player left of the button, one at a time.
|
||||
const SplitCalculator = {
|
||||
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||
toCents(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
},
|
||||
|
||||
split(potAmount, ways, chipAmount) {
|
||||
const potCents = this.toCents(potAmount);
|
||||
const chipCents = Math.max(1, this.toCents(chipAmount));
|
||||
const players = Math.max(1, Math.floor(Number(ways) || 0));
|
||||
|
||||
if (potCents <= 0) {
|
||||
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
|
||||
}
|
||||
|
||||
const wholeChips = Math.floor(potCents / chipCents);
|
||||
// Left over when the pot itself isn't a whole number of the smallest chip —
|
||||
// usually means the dealer picked the wrong denomination, so it's surfaced
|
||||
// rather than silently rolled into someone's stack.
|
||||
const dustCents = potCents - wholeChips * chipCents;
|
||||
|
||||
const chipsEach = Math.floor(wholeChips / players);
|
||||
const oddChips = wholeChips - chipsEach * players;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
players,
|
||||
each: (chipsEach * chipCents) / 100,
|
||||
oddChips,
|
||||
chip: chipCents / 100,
|
||||
dust: dustCents / 100,
|
||||
};
|
||||
},
|
||||
|
||||
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||
accountedFor(result) {
|
||||
return (
|
||||
Math.round(result.each * 100) * result.players +
|
||||
Math.round(result.chip * 100) * result.oddChips +
|
||||
Math.round(result.dust * 100)
|
||||
) / 100;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SplitCalculator;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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: [],
|
||||
};
|
||||
},
|
||||
|
||||
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: [],
|
||||
};
|
||||
},
|
||||
|
||||
load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
return parsed;
|
||||
} catch {
|
||||
return this.createDefault();
|
||||
}
|
||||
},
|
||||
|
||||
save(state) {
|
||||
try {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage full/unavailable (private browsing, etc.) — the table still works
|
||||
// for the rest of the session, it just won't survive a refresh.
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
resizeSeats(state, newCount) {
|
||||
const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
|
||||
const seats = state.seats.slice(0, count);
|
||||
while (seats.length < count) seats.push(this.makePlayer());
|
||||
state.seats = seats;
|
||||
state.seatCount = count;
|
||||
if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
|
||||
return state;
|
||||
},
|
||||
|
||||
// What to call a seat in logs, payouts and standings when the dealer hasn't
|
||||
// typed a name in yet.
|
||||
displayName(player, seatIndex) {
|
||||
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// Contribution the table calculator will treat this player as having put in.
|
||||
computeContribution(player, chipLegend, basePotAmount) {
|
||||
if (!player.inHand) return 0;
|
||||
|
||||
const override = player.amountOverride;
|
||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||
const parsed = Number(override);
|
||||
if (!Number.isNaN(parsed)) return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
const chipTotal = chipLegend.reduce((sum, chip) => {
|
||||
const count = Number(player.chipCounts[chip.id]) || 0;
|
||||
return sum + count * Number(chip.value || 0);
|
||||
}, 0);
|
||||
if (chipTotal > 0) return chipTotal;
|
||||
|
||||
const base = Number(basePotAmount);
|
||||
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||
},
|
||||
|
||||
totalPot(state) {
|
||||
return state.seats.reduce(
|
||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
0
|
||||
);
|
||||
},
|
||||
|
||||
// Next occupied-by-index seat in the current blind direction. Every seat index
|
||||
// counts (there's no separate "empty chair" concept) — a dealer running fewer
|
||||
// players just keeps the seat count matched to who's actually sitting down.
|
||||
nextSeatIndex(state, fromIndex, step = 1) {
|
||||
const count = state.seatCount;
|
||||
const delta = state.direction === "counterclockwise" ? -step : step;
|
||||
return ((fromIndex + delta) % count + count) % count;
|
||||
},
|
||||
|
||||
advanceButton(state) {
|
||||
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
},
|
||||
|
||||
getBlindSeats(state) {
|
||||
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
|
||||
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
|
||||
},
|
||||
|
||||
startNextHand(state) {
|
||||
this.advanceButton(state);
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
p.chipCounts = {};
|
||||
p.amountOverride = null;
|
||||
});
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
// for chip-count disputes, then advances the hand counter. `payouts` is
|
||||
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
||||
//
|
||||
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
||||
// it stores alongside the payouts is what session standings are summed from later,
|
||||
// and it needs this hand's contributions while they're still on the table.
|
||||
recordHand(state, payouts) {
|
||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||
|
||||
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
||||
const ledger = state.seats
|
||||
.map((player, index) => ({
|
||||
id: player.id,
|
||||
name: this.displayName(player, index),
|
||||
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
|
||||
won: wonById.get(player.id) || 0,
|
||||
}))
|
||||
.filter(row => row.in > 0 || row.won > 0);
|
||||
|
||||
state.handHistory.unshift({
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
|
||||
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
|
||||
}
|
||||
state.handNumber += 1;
|
||||
},
|
||||
|
||||
clearHistory(state) {
|
||||
state.handHistory = [];
|
||||
state.handNumber = 1;
|
||||
},
|
||||
|
||||
// Running per-player totals for the whole session, so a dealer settling up at the
|
||||
// end of the night reads one number per person instead of adding up every hand.
|
||||
// Keyed by seat id, which survives renames and reseating; hands recorded before
|
||||
// the ledger existed simply don't contribute to the totals.
|
||||
sessionStandings(state) {
|
||||
const totals = new Map();
|
||||
|
||||
(state.handHistory || []).forEach(entry => {
|
||||
(entry.ledger || []).forEach(row => {
|
||||
// History is newest-first, so the first name seen for an id is the
|
||||
// most recent one the dealer typed.
|
||||
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
|
||||
running.in += row.in;
|
||||
running.won += row.won;
|
||||
totals.set(row.id, running);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(totals.values())
|
||||
.map(t => ({
|
||||
...t,
|
||||
in: Math.round(t.in * 100) / 100,
|
||||
won: Math.round(t.won * 100) / 100,
|
||||
net: Math.round((t.won - t.in) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.net - a.net);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HoldemState;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */
|
||||
* Internally it points to your engine state object. */
|
||||
typedef struct ChessEngine* EngineHandle;
|
||||
|
||||
/* Opaque per-game training accumulator (see the learned-weights ABI at the bottom). */
|
||||
typedef struct Trainer* TrainerHandle;
|
||||
|
||||
/* Return codes. 0 == success; negative == error. Keep these values stable. */
|
||||
enum {
|
||||
CHESS_OK = 0,
|
||||
@@ -79,6 +82,36 @@ CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
|
||||
/* Destroy an instance created by engine_create. Safe to call with NULL. */
|
||||
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine);
|
||||
|
||||
/* ---- Learned-weights / training ABI ----
|
||||
* The learned engine's weights live process-globally here. The host orchestrates games but
|
||||
* owns no chess logic: it points the engine at the weights file, records each played
|
||||
* position, and applies the game's result. */
|
||||
|
||||
/* Load the global learned weights from `path` and remember it for later saves. Idempotent;
|
||||
* a missing/short file leaves the weights neutral. Call once before learned play/training. */
|
||||
CHESS_API void CHESS_CALL learned_load(const char* path);
|
||||
|
||||
/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame (PAWN..KING,
|
||||
* squares 0..63) + feature weights. Returns the count written, or CHESS_ERR_BUFFER if
|
||||
* out_len is too small (needs >= 776). */
|
||||
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len);
|
||||
|
||||
/* Create / destroy a per-game training accumulator. Safe to destroy NULL. */
|
||||
CHESS_API TrainerHandle CHESS_CALL trainer_create(void);
|
||||
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
|
||||
|
||||
/* Record one played position (post-move FEN) into the accumulator. */
|
||||
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
|
||||
|
||||
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
|
||||
* on a square, or a feature) tracks a presence-weighted running average of the game's
|
||||
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
|
||||
* draw) minus how much of that outcome the winner's average material edge already explains —
|
||||
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
|
||||
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
|
||||
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
|
||||
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
@@ -76,10 +79,79 @@ static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_
|
||||
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
||||
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
|
||||
|
||||
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
|
||||
* shared g_tt above. */
|
||||
/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
|
||||
* material + learned phase-split piece-square tables + learned feature weights. */
|
||||
enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 };
|
||||
|
||||
/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned
|
||||
* from game outcomes; its activation is computed by compute_features(). Mobility is per
|
||||
* piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */
|
||||
enum Feature : int {
|
||||
FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */
|
||||
FEAT_PASSED, /* passed pawns, endgame-weighted */
|
||||
FEAT_ISOLATED, /* isolated pawns */
|
||||
FEAT_DOUBLED, /* doubled pawns */
|
||||
FEAT_KING, /* king pawn-shelter, midgame-weighted */
|
||||
FEATURE_NB
|
||||
};
|
||||
|
||||
/* Per-handle eval configuration, snapshotted from the global learned weights at
|
||||
* engine_create so the search reads a stable copy. The tables are white-relative: a black
|
||||
* piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase.
|
||||
* Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */
|
||||
struct EvalParams {
|
||||
int variant = EVAL_CLASSIC;
|
||||
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int featW[FEATURE_NB] = {};
|
||||
};
|
||||
|
||||
/* Internal engine state. One ChessEngine = one game. The transposition table is NOT
|
||||
* here: it is the shared g_tt above. */
|
||||
struct ChessEngine {
|
||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||
EvalParams eval; /* which evaluation the search uses, plus any learned weights */
|
||||
};
|
||||
|
||||
/* The process-global learned weights: the single source of truth, loaded from disk once and
|
||||
* updated in place by training. Engine handles snapshot it at creation; the visualization
|
||||
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
|
||||
* per-handle copy, so it never touches this concurrently).
|
||||
*
|
||||
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
|
||||
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
|
||||
* the presence-weighted count of games that touched the cell (how much to trust the average);
|
||||
* score is the presence-weighted sum of each game's material-adjusted advantage (see
|
||||
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
|
||||
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
|
||||
struct LearnedWeights {
|
||||
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||
int featW[FEATURE_NB] = {};
|
||||
|
||||
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||
double mgScore [chess::PIECE_TYPE_NB][64] = {};
|
||||
double egVisits[chess::PIECE_TYPE_NB][64] = {};
|
||||
double egScore [chess::PIECE_TYPE_NB][64] = {};
|
||||
double featVisits[FEATURE_NB] = {};
|
||||
double featScore [FEATURE_NB] = {};
|
||||
};
|
||||
|
||||
static LearnedWeights g_weights;
|
||||
static std::mutex g_weightsMutex;
|
||||
static std::string g_weightsPath;
|
||||
|
||||
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
|
||||
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
|
||||
* activations, and the white-minus-black material balance; trainer_apply turns the totals
|
||||
* into a presence-weighted update to the running per-cell averages. Squares are white-relative
|
||||
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
|
||||
struct Trainer {
|
||||
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
|
||||
int plies = 0;
|
||||
};
|
||||
|
||||
static int copy_out(const char* src, char* out_buf, int out_len) {
|
||||
@@ -108,6 +180,83 @@ static int parse_skill(const char* options, int fallback) {
|
||||
return v < 1 ? 1 : v > 20 ? 20 : v;
|
||||
}
|
||||
|
||||
/* "variant=learned" in the options selects the learned eval; anything else is classic. */
|
||||
static int parse_variant(const char* options) {
|
||||
if (!options) return EVAL_CLASSIC;
|
||||
const char* p = std::strstr(options, "variant=");
|
||||
if (!p) return EVAL_CLASSIC;
|
||||
return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC;
|
||||
}
|
||||
|
||||
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
|
||||
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
|
||||
* egVisits, egScore, featVisits, featScore) in the same shapes, all whitespace-separated. A
|
||||
* missing file or short read leaves the rest neutral (0), so an absent weights file just means
|
||||
* "train from a blank slate" — and a file written by the pre-fractional-learning format (which
|
||||
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
|
||||
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
|
||||
static void load_global_weights(const char* path) {
|
||||
g_weights = LearnedWeights{}; /* reset to neutral before loading */
|
||||
|
||||
if (!path || !*path) return;
|
||||
std::ifstream f(path);
|
||||
if (!f) return;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.mg[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.eg[pt][sq])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featW[i])) return;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.mgVisits[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.mgScore[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.egVisits[pt][sq])) return;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq)
|
||||
if (!(f >> g_weights.egScore[pt][sq])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featVisits[i])) return;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
if (!(f >> g_weights.featScore[i])) return;
|
||||
}
|
||||
|
||||
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
|
||||
* precision on the doubles (visits/score are running sums that keep accumulating across many
|
||||
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
|
||||
* lock. */
|
||||
static void save_global_weights() {
|
||||
if (g_weightsPath.empty()) return;
|
||||
std::ofstream f(g_weightsPath);
|
||||
if (!f) return;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.mg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
|
||||
f << std::setprecision(17);
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||
}
|
||||
|
||||
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
|
||||
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
||||
static int depth_for_skill(int skill) {
|
||||
@@ -208,14 +357,32 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const
|
||||
return score;
|
||||
}
|
||||
|
||||
static int piece_value(chess::PieceType pt) {
|
||||
switch (pt) {
|
||||
case chess::PAWN: return 100;
|
||||
case chess::KNIGHT: return 320;
|
||||
case chess::BISHOP: return 330;
|
||||
case chess::ROOK: return 500;
|
||||
case chess::QUEEN: return 900;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int castleIncentive(const chess::Position& pos, chess::Color c) {
|
||||
if (!pos.pieces(chess::QUEEN))
|
||||
return 0;
|
||||
chess::Bitboard pcs = pos.pieces();
|
||||
int total = 0;
|
||||
while (pcs) {
|
||||
chess::Square s = chess::pop_lsb(pcs);
|
||||
chess::Piece pc = pos.piece_on(s);
|
||||
chess::Color c = chess::color_of(pc);
|
||||
total += piece_value(chess::type_of(pc));
|
||||
}
|
||||
|
||||
chess::Square k = pos.king_square(c);
|
||||
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
|
||||
: (k == chess::G8 || k == chess::C8);
|
||||
|
||||
return castled ? 600 : 0;
|
||||
return castled ? (total / 10) : 0;
|
||||
}
|
||||
|
||||
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
|
||||
@@ -261,10 +428,141 @@ static int evaluate(const chess::Position& pos) {
|
||||
return score;
|
||||
}
|
||||
|
||||
/* ---- Learned (phase-split tables + feature knobs) evaluation ---------------------------
|
||||
* The model is a linear combination of features whose weights are learned from outcomes:
|
||||
* eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i]
|
||||
* compute_features() is the single source of feature activations, used by BOTH the eval here
|
||||
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
|
||||
|
||||
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
|
||||
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
|
||||
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
|
||||
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
|
||||
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
|
||||
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
|
||||
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
|
||||
* explains, so training credits placement/features only for the part of the result that
|
||||
* material didn't already predict. */
|
||||
static constexpr int SQ_OUT_SCALE = 250;
|
||||
static constexpr int FEAT_OUT_SCALE = 500;
|
||||
static constexpr double MATERIAL_ELO_SCALE = 400.0;
|
||||
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
|
||||
|
||||
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
|
||||
* 24 for both full sides): 0 = opening, 1 = bare kings. Drives the mg/eg table blend and
|
||||
* the phase weighting of the passed-pawn (×phase) and king-safety (×(1−phase)) features. */
|
||||
static double game_phase(const chess::Position& pos) {
|
||||
int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1
|
||||
+ chess::popcount(pos.pieces(chess::BISHOP)) * 1
|
||||
+ chess::popcount(pos.pieces(chess::ROOK)) * 2
|
||||
+ chess::popcount(pos.pieces(chess::QUEEN)) * 4;
|
||||
constexpr int MAX = 24;
|
||||
if (npm >= MAX) return 0.0;
|
||||
return double(MAX - npm) / MAX;
|
||||
}
|
||||
|
||||
/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a
|
||||
* square the same way the eval reads it). */
|
||||
static int blend(int mg, int eg, double phase) {
|
||||
return int(std::lround((1.0 - phase) * mg + phase * eg));
|
||||
}
|
||||
|
||||
/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position. The piece-
|
||||
* square tables handle "where pieces belong"; these capture context a static table can't:
|
||||
* legal mobility (per piece type, so pins reduce it), passed pawns (endgame-weighted), pawn
|
||||
* structure, and king shelter (midgame-weighted). Ported nowhere — this is the only copy. */
|
||||
static void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) {
|
||||
for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0;
|
||||
|
||||
/* Mobility: legal moves for color c, bucketed by the moving piece's type. */
|
||||
chess::MoveList moves;
|
||||
pos.generate_legal_for(c, moves);
|
||||
for (int i = 0; i < moves.size(); ++i) {
|
||||
switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) {
|
||||
case chess::KNIGHT: out[FEAT_MOB_N] += 1; break;
|
||||
case chess::BISHOP: out[FEAT_MOB_B] += 1; break;
|
||||
case chess::ROOK: out[FEAT_MOB_R] += 1; break;
|
||||
case chess::QUEEN: out[FEAT_MOB_Q] += 1; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pawn structure. */
|
||||
chess::Bitboard pawns = pos.pieces(c, chess::PAWN);
|
||||
chess::Bitboard bb = pawns;
|
||||
while (bb) {
|
||||
chess::Square s = chess::pop_lsb(bb);
|
||||
|
||||
if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */
|
||||
chess::Rank r = chess::rank_of(s);
|
||||
int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);
|
||||
out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */
|
||||
}
|
||||
if (front_span_file_only(c, s) & pawns) /* doubled (friendly pawn ahead) */
|
||||
out[FEAT_DOUBLED] += 1;
|
||||
|
||||
chess::File f = chess::file_of(s);
|
||||
chess::Bitboard adjacent = 0;
|
||||
if (f > chess::FILE_A) adjacent |= chess::file_bb(chess::File(f - 1));
|
||||
if (f < chess::FILE_H) adjacent |= chess::file_bb(chess::File(f + 1));
|
||||
if (!(adjacent & pawns)) /* isolated */
|
||||
out[FEAT_ISOLATED] += 1;
|
||||
}
|
||||
|
||||
/* King safety: friendly pawns sheltering the king (its file + adjacent files, the two
|
||||
* ranks in front), worth more in the midgame. */
|
||||
chess::Square k = pos.king_square(c);
|
||||
chess::File kf = chess::file_of(k);
|
||||
chess::Rank kr = chess::rank_of(k);
|
||||
chess::Bitboard kingFiles = chess::file_bb(kf);
|
||||
if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1));
|
||||
if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1));
|
||||
chess::Bitboard shelterRanks = 0;
|
||||
for (int d = 1; d <= 2; ++d) {
|
||||
int rr = (c == chess::WHITE) ? (kr + d) : (kr - d);
|
||||
if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr));
|
||||
}
|
||||
out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase);
|
||||
}
|
||||
|
||||
/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece-
|
||||
* square tables + learned feature weights. Black pieces index the rank-mirrored square
|
||||
* (s ^ 56) so both colors share one white-relative table. Non-const because mobility
|
||||
* generates legal moves (which the position's move generator does via do/undo). */
|
||||
static int evaluateLearned(chess::Position& pos, const EvalParams& ep) {
|
||||
double phase = game_phase(pos);
|
||||
int score = 0;
|
||||
|
||||
chess::Bitboard white = pos.pieces(chess::WHITE);
|
||||
while (white) {
|
||||
chess::Square s = chess::pop_lsb(white);
|
||||
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||
score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase);
|
||||
}
|
||||
|
||||
chess::Bitboard black = pos.pieces(chess::BLACK);
|
||||
while (black) {
|
||||
chess::Square s = chess::pop_lsb(black);
|
||||
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||
score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase);
|
||||
}
|
||||
|
||||
double wFeat[FEATURE_NB], bFeat[FEATURE_NB];
|
||||
compute_features(pos, chess::WHITE, phase, wFeat);
|
||||
compute_features(pos, chess::BLACK, phase, bFeat);
|
||||
|
||||
double feature = 0.0;
|
||||
for (int i = 0; i < FEATURE_NB; ++i)
|
||||
feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i];
|
||||
score += int(std::lround(feature));
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
|
||||
* move, so flip the sign when black is to move. */
|
||||
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
||||
int s = evaluate(pos);
|
||||
static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) {
|
||||
int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos);
|
||||
return whiteToMove ? s : -s;
|
||||
}
|
||||
|
||||
@@ -274,17 +572,6 @@ static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
||||
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
|
||||
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
|
||||
|
||||
static int piece_value(chess::PieceType pt) {
|
||||
switch (pt) {
|
||||
case chess::PAWN: return 100;
|
||||
case chess::KNIGHT: return 320;
|
||||
case chess::BISHOP: return 330;
|
||||
case chess::ROOK: return 500;
|
||||
case chess::QUEEN: return 900;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
|
||||
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
|
||||
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
|
||||
@@ -351,6 +638,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||
auto* e = new (std::nothrow) ChessEngine();
|
||||
if (!e) return nullptr;
|
||||
e->skill = parse_skill(options, e->skill);
|
||||
e->eval.variant = parse_variant(options);
|
||||
if (e->eval.variant == EVAL_LEARNED) {
|
||||
/* Snapshot the current global weights so the search reads a stable copy (training
|
||||
* updates the global between games; the weights path is owned by learned_load). */
|
||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||
std::memcpy(e->eval.mg, g_weights.mg, sizeof e->eval.mg);
|
||||
std::memcpy(e->eval.eg, g_weights.eg, sizeof e->eval.eg);
|
||||
std::memcpy(e->eval.featW, g_weights.featW, sizeof e->eval.featW);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -369,8 +665,9 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
||||
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
|
||||
|
||||
struct SearchContext {
|
||||
uint64_t nodes = 0;
|
||||
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
|
||||
uint64_t nodes = 0;
|
||||
const EvalParams* eval = nullptr; /* eval config for this search; set by engine_best_move */
|
||||
chess::Move killers[MAX_PLY][2] = {};/* [ply][slot]; MOVE_NONE until filled */
|
||||
};
|
||||
|
||||
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
|
||||
@@ -387,7 +684,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
|
||||
return 0;
|
||||
|
||||
if (depth <= 0)
|
||||
return evaluate_stm(pos, whiteToMove);
|
||||
return evaluate_stm(pos, whiteToMove, *ctx.eval);
|
||||
|
||||
const uint64_t key = pos.key();
|
||||
TTEntry& slot = g_tt.entries[key & g_tt.mask];
|
||||
@@ -502,6 +799,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||
return CHESS_ERR_NO_MOVE;
|
||||
|
||||
SearchContext ctx;
|
||||
ctx.eval = &engine->eval;
|
||||
int maxDepth = depth_for_skill(engine->skill);
|
||||
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
|
||||
|
||||
@@ -547,4 +845,135 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) {
|
||||
delete engine; /* delete nullptr is safe */
|
||||
}
|
||||
|
||||
/* ---- Learned-weights / training C ABI --------------------------------------------------
|
||||
* The managed side orchestrates games but owns no chess logic: it tells the engine where
|
||||
* to load/save the global weights, records each played position, and applies the result. */
|
||||
|
||||
CHESS_API void CHESS_CALL learned_load(const char* path) {
|
||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||
g_weightsPath = path ? path : "";
|
||||
load_global_weights(path);
|
||||
}
|
||||
|
||||
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
|
||||
const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */
|
||||
if (!out || out_len < need) return CHESS_ERR_BUFFER;
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||
int n = 0;
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq];
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq];
|
||||
for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i];
|
||||
return n;
|
||||
}
|
||||
|
||||
CHESS_API TrainerHandle CHESS_CALL trainer_create(void) {
|
||||
return new (std::nothrow) Trainer();
|
||||
}
|
||||
|
||||
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||
if (!t || !fen || !*fen) return;
|
||||
ensure_initialized();
|
||||
|
||||
chess::Position pos = chess::Position::from_fen(fen);
|
||||
double phase = game_phase(pos);
|
||||
|
||||
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
|
||||
* white-minus-black material balance at this ply, used at trainer_apply time to strip
|
||||
* "material already explains this" out of the training signal. */
|
||||
chess::Bitboard occ = pos.pieces();
|
||||
while (occ) {
|
||||
chess::Square s = chess::pop_lsb(occ);
|
||||
chess::Piece pc = pos.piece_on(s);
|
||||
chess::Color c = chess::color_of(pc);
|
||||
chess::PieceType pt = chess::type_of(pc);
|
||||
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
|
||||
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
||||
t->egOcc[c][pt][relSq] += phase;
|
||||
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
|
||||
}
|
||||
|
||||
/* Per-side feature activations. */
|
||||
double w[FEATURE_NB], b[FEATURE_NB];
|
||||
compute_features(pos, chess::WHITE, phase, w);
|
||||
compute_features(pos, chess::BLACK, phase, b);
|
||||
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||
t->featAcc[chess::WHITE][i] += w[i];
|
||||
t->featAcc[chess::BLACK][i] += b[i];
|
||||
}
|
||||
|
||||
t->plies++;
|
||||
}
|
||||
|
||||
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
|
||||
* of an unbounded incremental nudge:
|
||||
*
|
||||
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
|
||||
* on this square, minus fraction lost"
|
||||
*
|
||||
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
|
||||
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
|
||||
* result ISN'T already explained by material: `expected` is the win probability the winner's
|
||||
* average material edge over the game implies (the same centipawn->win-probability curve
|
||||
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
|
||||
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
|
||||
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
|
||||
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
|
||||
* held) while materially behind is highly informative and gets a correspondingly large update.
|
||||
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
|
||||
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
|
||||
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
|
||||
* expected`, just negative. */
|
||||
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
|
||||
if (!t || t->plies <= 0) return;
|
||||
|
||||
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
|
||||
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
|
||||
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
|
||||
double advantageMag = weight * (1.0 - expected);
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||
|
||||
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
|
||||
for (int pass = 0; pass < 2; ++pass) {
|
||||
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
|
||||
double advantage = pass == 0 ? advantageMag : -advantageMag;
|
||||
|
||||
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||
for (int sq = 0; sq < 64; ++sq) {
|
||||
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
|
||||
if (mgFrac != 0.0) {
|
||||
g_weights.mgVisits[pt][sq] += mgFrac;
|
||||
g_weights.mgScore [pt][sq] += mgFrac * advantage;
|
||||
g_weights.mg[pt][sq] = int(std::lround(
|
||||
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
|
||||
}
|
||||
double egFrac = t->egOcc[side][pt][sq] / t->plies;
|
||||
if (egFrac != 0.0) {
|
||||
g_weights.egVisits[pt][sq] += egFrac;
|
||||
g_weights.egScore [pt][sq] += egFrac * advantage;
|
||||
g_weights.eg[pt][sq] = int(std::lround(
|
||||
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
|
||||
if (activation == 0.0) continue;
|
||||
g_weights.featVisits[i] += std::abs(activation);
|
||||
g_weights.featScore [i] += activation * advantage;
|
||||
g_weights.featW[i] = int(std::lround(
|
||||
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
|
||||
}
|
||||
}
|
||||
|
||||
save_global_weights();
|
||||
}
|
||||
|
||||
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) {
|
||||
delete t; /* delete nullptr is safe */
|
||||
}
|
||||
|
||||
} /* extern "C" */
|
||||
|
||||
@@ -47,6 +47,22 @@ public:
|
||||
void do_move(Move m);
|
||||
void undo_move(Move m);
|
||||
|
||||
// Legal moves for a SPECIFIED color (for mobility eval of either side). When c is not
|
||||
// the side to move, temporarily flips side-to-move (and clears the en-passant square,
|
||||
// which belongs to the other side) so generate_legal runs for c, then restores. The
|
||||
// Zobrist key is untouched and unused by move generation, so this leaves the position
|
||||
// observably unchanged.
|
||||
void generate_legal_for(Color c, MoveList& list) {
|
||||
if (sideToMove == c) { generate_legal(list); return; }
|
||||
Color savedSide = sideToMove;
|
||||
Square savedEp = epSquare;
|
||||
sideToMove = c;
|
||||
epSquare = SQ_NONE;
|
||||
generate_legal(list);
|
||||
sideToMove = savedSide;
|
||||
epSquare = savedEp;
|
||||
}
|
||||
|
||||
// Seed prior-position keys (oldest first, excluding the current position) so
|
||||
// is_draw() can see game history the FEN doesn't carry. Call once, right after
|
||||
// from_fen and before any do_move.
|
||||
|
||||
Reference in New Issue
Block a user