Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3851786b90 | ||
|
|
25bf67062a | ||
|
|
98f95df8fb | ||
|
|
b7c9227bb8 | ||
|
|
ddb309f6db | ||
|
|
2db4048ed2 | ||
|
|
2f53c2f0ed | ||
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 | ||
|
|
f956f4bffa | ||
|
|
a53b0c2e43 | ||
|
|
edc4d70678 | ||
|
|
3bfcbaa190 | ||
|
|
59b318b75c | ||
|
|
e5e0c9cc48 | ||
|
|
3947bd0daf | ||
|
|
b99525ae51 | ||
|
|
9da7b1986b | ||
|
|
a0b8c2602a | ||
|
|
7604798e68 | ||
|
|
02163a4fc4 | ||
|
|
1680d13b05 | ||
|
|
a1e2ad9c2d | ||
|
|
26b4c0e59b | ||
|
|
aeef320cb4 | ||
|
|
d71e485611 | ||
|
|
d09a067377 | ||
|
|
acbee436bb | ||
|
|
d452ee414b | ||
|
|
f09ca60315 | ||
|
|
743c395e11 | ||
|
|
62e146648c | ||
|
|
6402ef268c | ||
|
|
c92c46e6d4 | ||
|
|
e8a3bfe432 | ||
|
|
8b78fd589c | ||
|
|
be07f7504e | ||
|
|
7bfbae7301 | ||
|
|
b651683e63 | ||
|
|
d87cea572f | ||
|
|
5345427e0c | ||
|
|
76b054ee9d | ||
|
|
2db95cec34 | ||
|
|
9b69489c4e | ||
|
|
fc2a2e122b | ||
|
|
c3280aa6d3 | ||
|
|
d2ae34f530 | ||
|
|
52d468a46e | ||
|
|
7fcfb270a1 | ||
|
|
ea60ee5a4b | ||
|
|
3dc1bc452e | ||
|
|
99a9474ef7 | ||
|
|
4884c32fc8 | ||
|
|
8043eee4cc | ||
|
|
5fd3a5d7c1 |
@@ -4,9 +4,8 @@
|
||||
"Bash(dotnet test:*)",
|
||||
"Bash(dotnet build)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(node --check:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(node build.js:*)"
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(dotnet build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
# Gitea's runner images are lean, so the toolchain is installed below rather
|
||||
# than assumed. The glibc that libchess_engine.so links against comes from
|
||||
# this image, not from the runner host — keep it matched to the prod server.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install toolchain
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
cmake build-essential rsync openssh-client
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||
|
||||
- name: Build native chess engine (libchess_engine.so)
|
||||
run: |
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build
|
||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||
|
||||
# Publishing the project rather than the solution keeps chess_engine.vcxproj
|
||||
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
|
||||
- name: Publish
|
||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||
|
||||
- name: Verify payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f publish/Resources/libchess_engine.so \
|
||||
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
|
||||
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|
||||
|| { echo "stockfish is not executable"; exit 1; }
|
||||
|
||||
- name: Prepare SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||
- name: Rsync to server
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
TARGET_DIR: ${{ secrets.TARGET_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
rsync -az --delete --exclude 'chess-data' \
|
||||
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
||||
|
||||
- name: Reload and restart service
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
|
||||
"sudo systemctl daemon-reload \
|
||||
&& sudo systemctl restart '$SERVICE_NAME' \
|
||||
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
|
||||
@@ -14,7 +14,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
|
||||
# compiled libchess_engine.so loads there. Keep this == the server's release.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,9 +26,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x' # adjust if needed
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||
|
||||
- name: Build native chess engine (libchess_engine.so)
|
||||
run: |
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build
|
||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -o ./publish
|
||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -45,6 +54,12 @@ jobs:
|
||||
name: site-publish
|
||||
path: publish
|
||||
|
||||
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||
# carries it to the server, where the service user can run it regardless of file owner.
|
||||
- name: Restore Stockfish executable bit
|
||||
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
@@ -54,7 +69,9 @@ jobs:
|
||||
|
||||
- name: Rsync to server
|
||||
run: |
|
||||
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||
|
||||
- name: Reload and restart service
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net.slnf
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net.slnf
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
|
||||
+7
-1
@@ -3,6 +3,9 @@
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# Learned chess engine weights (runtime training output, not source)
|
||||
chess-data/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
@@ -360,4 +363,7 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
+36
-2
@@ -1,26 +1,60 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34728.123
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.6.11822.322 stable
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "JoshHeaps.Net.sln",
|
||||
"projects": [
|
||||
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
||||
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: My First Post
|
||||
date: 2026-03-06
|
||||
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
|
||||
tags: [dev, personal]
|
||||
---
|
||||
|
||||
# Welcome to My Blog
|
||||
|
||||
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
|
||||
|
||||
## What to Expect
|
||||
|
||||
I plan to write about:
|
||||
|
||||
- **Projects** I'm working on, like this website and the other things on my portfolio
|
||||
- **Software development** tips and patterns I find useful
|
||||
- **Problem solving** approaches that have helped me grow as a developer
|
||||
|
||||
## Why a Blog?
|
||||
|
||||
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
|
||||
|
||||
Stay tuned for more posts!
|
||||
@@ -0,0 +1,20 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpPost("invalidate")]
|
||||
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
|
||||
{
|
||||
var expectedKey = configuration["BlogApi:InvalidateKey"];
|
||||
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
|
||||
return Unauthorized();
|
||||
|
||||
blogService.ClearCache();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
@@ -11,16 +11,15 @@ namespace JoshHeaps.Net.Controllers;
|
||||
[Route("api/[controller]")]
|
||||
public class ChessController(
|
||||
IChessService chessService,
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IBackgroundTaskQueue queue) : ControllerBase
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore,
|
||||
ISelfPlayCoordinator selfPlay,
|
||||
AutoTrainingSettings autoTraining,
|
||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Store of ongoing games.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||
@@ -30,38 +29,45 @@ public class ChessController(
|
||||
/// </summary>
|
||||
[HttpGet("new")]
|
||||
[HttpGet("new/{difficulty}")]
|
||||
public ActionResult CreateGame(int difficulty = 20)
|
||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
Guid playerId = Guid.NewGuid();
|
||||
Guid computerId = Guid.NewGuid();
|
||||
var isWhite = Random.Shared.Next(2) == 0;
|
||||
var isWhite = color.ToLowerInvariant() switch
|
||||
{
|
||||
"white" => true,
|
||||
"black" => false,
|
||||
_ => Random.Shared.Next(2) == 0,
|
||||
};
|
||||
|
||||
gameState.Computer = new(difficulty);
|
||||
var computer = engineFactory.Create(difficulty);
|
||||
|
||||
if (isWhite)
|
||||
{
|
||||
gameState.WhitePlayerId = playerId;
|
||||
gameState.BlackPlayerId = computerId;
|
||||
gameState.BlackComputer = computer;
|
||||
}
|
||||
else
|
||||
{
|
||||
gameState.WhitePlayerId = computerId;
|
||||
gameState.BlackPlayerId = playerId;
|
||||
gameState.WhiteComputer = computer;
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give user's browser time to connect to signalR and such.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
|
||||
await orchestrator.PlayAsync(gameState);
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -71,6 +77,55 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
||||
/// chosen independently; when the learned engine plays, the game also trains it.
|
||||
/// </summary>
|
||||
[HttpGet("watch/cpu")]
|
||||
[HttpGet("watch/cpu/{difficulty}")]
|
||||
public ActionResult CreateSelfPlayGame(
|
||||
int difficulty = 4,
|
||||
string whiteEngine = "custom",
|
||||
string blackEngine = "custom",
|
||||
int? whiteSkill = null,
|
||||
int? blackSkill = null)
|
||||
{
|
||||
var config = new SelfPlayConfig(
|
||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||
|
||||
var (gameId, _) = selfPlay.StartGame(config);
|
||||
|
||||
return Ok(new { GameId = gameId });
|
||||
}
|
||||
|
||||
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"stockfish" => ChessEngineKind.Stockfish,
|
||||
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
||||
_ => ChessEngineKind.Custom
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||
/// </summary>
|
||||
[HttpGet("autotrain")]
|
||||
public ActionResult GetAutoTrain() =>
|
||||
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
|
||||
/// <summary>
|
||||
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||
/// </summary>
|
||||
[HttpPost("autotrain")]
|
||||
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||
{
|
||||
autoTraining.GameCount = count; // clamped inside the setter
|
||||
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the "pool" of chess players.
|
||||
/// Test code expects to receive a GUID for the player
|
||||
@@ -80,12 +135,12 @@ public class ChessController(
|
||||
public ActionResult JoinGame()
|
||||
{
|
||||
Console.WriteLine("joining game");
|
||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
||||
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||
|
||||
if (gameState == null)
|
||||
{
|
||||
gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
}
|
||||
|
||||
Guid playerId = Guid.NewGuid();
|
||||
@@ -103,7 +158,7 @@ public class ChessController(
|
||||
isWhite = false;
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -113,51 +168,72 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public ActionResult GetActiveGames()
|
||||
{
|
||||
var activeGames = gameStore.All
|
||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||
.Select(g => new
|
||||
{
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||
BlackEngine = g.BlackEngineKind.ToString(),
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
})
|
||||
.OrderByDescending(g => g.MoveCount);
|
||||
|
||||
return Ok(activeGames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
||||
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
||||
/// </summary>
|
||||
[HttpGet("weights")]
|
||||
public ActionResult GetLearnedWeights()
|
||||
{
|
||||
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
||||
|
||||
var snapshot = weightsStore.Snapshot();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
||||
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
||||
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}")]
|
||||
public ActionResult GetGameState(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var response = new
|
||||
{
|
||||
gameState.GameId,
|
||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
Pieces = gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new {
|
||||
p.Id,
|
||||
p.Type,
|
||||
p.Color,
|
||||
p.Position.Row,
|
||||
p.Position.Col,
|
||||
p.HasMoved
|
||||
}),
|
||||
gameState.MoveHistory
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
return Ok(gameState.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make a move in the specified game.
|
||||
/// Make a move in the specified game.
|
||||
/// The test passes a JSON body with a MoveDto.
|
||||
/// </summary>
|
||||
[HttpPost("move")]
|
||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
||||
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||
{
|
||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
||||
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
// Check if player is authorized to move
|
||||
@@ -181,17 +257,73 @@ public class ChessController(
|
||||
if (!result.Success)
|
||||
return BadRequest(result);
|
||||
|
||||
if (result.IsCheckmate || result.IsStalemate)
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
else if (gameState.IsVsComputer)
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
else
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||
var state = gameState.ToDto();
|
||||
|
||||
return Ok(result);
|
||||
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||
// game. The mover also receives this echo but drops it via the version guard,
|
||||
// since it already rendered the same state from this response.
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||
|
||||
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
|
||||
? gameState.WhiteComputer
|
||||
: gameState.BlackComputer;
|
||||
|
||||
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState));
|
||||
|
||||
return Ok(new { result, state });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
|
||||
/// Used when a player abandons a game (e.g. starts a new one mid-game).
|
||||
/// </summary>
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||
{
|
||||
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
return Ok();
|
||||
|
||||
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
|
||||
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
|
||||
|
||||
if (!isWhitePlayer && !isBlackPlayer)
|
||||
return StatusCode(403, "You are not a player in this game.");
|
||||
|
||||
gameState.IsForfeited = true;
|
||||
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
|
||||
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,7 +332,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||
@@ -214,7 +346,7 @@ public class ChessController(
|
||||
[HttpGet("{gameId}/legalMoves")]
|
||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||
@@ -226,39 +358,4 @@ public class ChessController(
|
||||
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_gameRemovalCancellationTokens[id] = cts;
|
||||
|
||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cts.Token);
|
||||
|
||||
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
||||
await game.Computer.DisposeAsync();
|
||||
|
||||
_games.Remove(id, out _);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||
{
|
||||
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Hubs;
|
||||
|
||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||
}
|
||||
|
||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
||||
{
|
||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
||||
}
|
||||
|
||||
public async Task LeaveWebsocketGroup(string gameId)
|
||||
{
|
||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,4 +29,16 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
|
||||
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
|
||||
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
|
||||
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
|
||||
managed reference to the native DLL. -->
|
||||
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
|
||||
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DateTime PublishedDate { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class ForfeitDto
|
||||
{
|
||||
public Guid GameId { get; set; }
|
||||
public Guid PlayerId { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
@@ -28,10 +29,25 @@ public class GameState
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
public bool IsForfeited { get; set; } = false;
|
||||
|
||||
// The color that won, when the game ended by forfeit (null while the game is live).
|
||||
public PieceColor? Winner { get; set; }
|
||||
|
||||
// Keep a history of moves if desired
|
||||
public List<string> MoveHistory { get; set; }
|
||||
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||
public List<string> SanHistory { get; set; }
|
||||
|
||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||
public int HalfmoveClock { get; set; }
|
||||
|
||||
// A list of all pieces to quickly reference them (optional but convenient).
|
||||
// Alternatively, you can iterate the Board array.
|
||||
public List<ChessPiece> Pieces { get; set; }
|
||||
@@ -42,8 +58,22 @@ public class GameState
|
||||
public Guid BlackPlayerId { get; set; }
|
||||
|
||||
public bool IsVsComputer { get; set; } = false;
|
||||
public bool IsComputerVsComputer { get; set; } = false;
|
||||
|
||||
public Stockfish? Computer { get; set; }
|
||||
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
||||
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
||||
public IChessEngine? WhiteComputer { get; set; }
|
||||
public IChessEngine? BlackComputer { get; set; }
|
||||
|
||||
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
||||
// are the learning engine, and lets spectators see who is playing.
|
||||
public ChessEngineKind WhiteEngineKind { get; set; }
|
||||
public ChessEngineKind BlackEngineKind { get; set; }
|
||||
|
||||
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
||||
// learned engine). The engine records each played position into it and applies the
|
||||
// result on game over.
|
||||
public nint Trainer { get; set; }
|
||||
|
||||
// optional: convenience
|
||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||
@@ -54,5 +84,7 @@ public class GameState
|
||||
Board = new ChessPiece[8, 8];
|
||||
Pieces = [];
|
||||
MoveHistory = [];
|
||||
PositionHistory = [];
|
||||
SanHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single piece as sent to the client. Captured pieces keep their original
|
||||
/// type and color; their position is meaningless and omitted.
|
||||
/// </summary>
|
||||
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||
|
||||
/// <summary>
|
||||
/// The full board state pushed to clients. The same shape is returned by the
|
||||
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||
/// client always renders from one authoritative payload instead of re-fetching.
|
||||
/// </summary>
|
||||
public record GameStateDto(
|
||||
Guid GameId,
|
||||
string CurrentPlayer,
|
||||
bool IsCheck,
|
||||
bool IsCheckmate,
|
||||
bool IsStalemate,
|
||||
bool IsThreefoldRepetition,
|
||||
string? EnPassantTarget,
|
||||
bool WhiteCanCastleKingside,
|
||||
bool WhiteCanCastleQueenside,
|
||||
bool BlackCanCastleKingside,
|
||||
bool BlackCanCastleQueenside,
|
||||
IReadOnlyList<ChessPieceDto> Pieces,
|
||||
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||
IReadOnlyList<string> MoveHistory,
|
||||
// Moves in standard algebraic notation, for the move-list panel.
|
||||
IReadOnlyList<string> SanHistory,
|
||||
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||
int Version);
|
||||
|
||||
public static class GameStateMapper
|
||||
{
|
||||
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||
gameState.GameId,
|
||||
gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
gameState.IsThreefoldRepetition,
|
||||
gameState.EnPassantTarget?.ToString(),
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row < 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.MoveHistory,
|
||||
gameState.SanHistory,
|
||||
gameState.MoveHistory.Count);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
|
||||
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
|
||||
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
|
||||
/// king safety).
|
||||
/// </summary>
|
||||
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
|
||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.BlogModel
|
||||
@{
|
||||
ViewData["Title"] = "Blog - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="blog-index">
|
||||
<h1 class="blog-title">Blog</h1>
|
||||
|
||||
@if (Model.ActiveTag is not null)
|
||||
{
|
||||
<div class="blog-filter">
|
||||
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
|
||||
<a href="/blog">Clear filter</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Posts.Count == 0)
|
||||
{
|
||||
<p class="blog-empty">No posts yet. Check back soon!</p>
|
||||
}
|
||||
|
||||
<div class="blog-list">
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<article class="blog-card">
|
||||
<a href="/blog/@post.Slug" class="blog-card-link">
|
||||
<h2 class="blog-card-title">@post.Title</h2>
|
||||
</a>
|
||||
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<p class="blog-card-summary">@post.Summary</p>
|
||||
<div class="blog-card-tags">
|
||||
@foreach (var tag in post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages;
|
||||
|
||||
public class BlogModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public List<BlogPost> Posts { get; set; } = [];
|
||||
public string? ActiveTag { get; set; }
|
||||
|
||||
public BlogModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(string? tag)
|
||||
{
|
||||
ActiveTag = tag;
|
||||
Posts = tag is not null
|
||||
? await _blogService.GetPostsByTagAsync(tag)
|
||||
: await _blogService.GetAllPostsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@page "/blog/{slug}"
|
||||
@model JoshHeaps.Net.Pages.Blog.PostModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<article class="blog-post">
|
||||
<a href="/blog" class="blog-back">← Back to blog</a>
|
||||
|
||||
<header class="blog-post-header">
|
||||
<h1 class="blog-post-title">@Model.Post.Title</h1>
|
||||
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<div class="blog-post-tags">
|
||||
@foreach (var tag in Model.Post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
@Html.Raw(Model.Post.HtmlContent)
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Blog;
|
||||
|
||||
public class PostModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public BlogPost? Post { get; set; }
|
||||
|
||||
public PostModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string slug)
|
||||
{
|
||||
Post = await _blogService.GetPostBySlugAsync(slug);
|
||||
|
||||
if (Post is null)
|
||||
return NotFound();
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@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>
|
||||
|
||||
<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,179 @@
|
||||
@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>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<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">
|
||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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/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/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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
}
|
||||
|
||||
<div id="chessContainer">
|
||||
<div id="boardContainer">
|
||||
<section id="boardArea">
|
||||
<div class="playerBar" id="topPlayerBar">
|
||||
<span class="playerDot black"></span>
|
||||
<span class="playerName">Opponent</span>
|
||||
<div class="capturedTray" id="captured-top"></div>
|
||||
<span class="advantage" id="advantage-top"></span>
|
||||
</div>
|
||||
|
||||
<div id="chessBoard">
|
||||
<!-- Placeholder squares -->
|
||||
@for (int i = 0; i < 64; i++)
|
||||
@@ -14,16 +21,41 @@
|
||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="textContainer" class="sideContent">
|
||||
<h1>Chess</h1>
|
||||
<p>Click a button to start a game :)</p>
|
||||
</div>
|
||||
<div class="playerBar" id="bottomPlayerBar">
|
||||
<span class="playerDot white"></span>
|
||||
<span class="playerName">You</span>
|
||||
<div class="capturedTray" id="captured-bottom"></div>
|
||||
<span class="advantage" id="advantage-bottom"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<aside id="gamePanel">
|
||||
<header class="panelHeader">
|
||||
<span class="panelLogo">♞</span>
|
||||
<h1>Chess</h1>
|
||||
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
||||
</header>
|
||||
|
||||
<div id="moveList">
|
||||
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
||||
</div>
|
||||
|
||||
<div id="statusLine">Start a game to play.</div>
|
||||
|
||||
<div id="panelButtons">
|
||||
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
|
||||
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
|
||||
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="menuBackdrop" onclick="closeMenu()"></div>
|
||||
|
||||
<div id="mobileBar">
|
||||
<span id="mobileStatus">Start a game to play.</span>
|
||||
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,19 +89,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="colorModal" style="display: none;">
|
||||
<p>Play as:</p>
|
||||
<div id="colorButtonContainer">
|
||||
<button onclick="selectColor('white')">White</button>
|
||||
<button onclick="selectColor('black')">Black</button>
|
||||
<button onclick="selectColor('random')">Random</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -1,115 +1,117 @@
|
||||
@page
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<header id="Header">
|
||||
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
|
||||
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
|
||||
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
|
||||
</header>
|
||||
|
||||
<div id="WelcomeMessage"></div>
|
||||
|
||||
<div id="ProjectsBox">
|
||||
<div id="ChessProject" class="projectContainer">
|
||||
<div class="displayBox diagonal-section-left">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml">
|
||||
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
||||
</a>
|
||||
</div>
|
||||
<div id="ChessText" class="projectTextRight">
|
||||
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
||||
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
||||
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)
|
||||
</p>
|
||||
</div>
|
||||
<nav id="Header">
|
||||
<span class="site-name">josh heaps</span>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
|
||||
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
|
||||
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
|
||||
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="CompilerProject" class="projectContainer">
|
||||
<div id="CompilerText" class="projectTextLeft">
|
||||
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
|
||||
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
|
||||
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
|
||||
</p>
|
||||
</div>
|
||||
<div class="displayBox diagonal-section-right">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
|
||||
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
|
||||
<section id="Hero">
|
||||
<div id="WelcomeMessage"></div>
|
||||
</section>
|
||||
|
||||
<section id="ProjectsBox">
|
||||
<h2 class="section-title">Projects</h2>
|
||||
<div class="projects-grid">
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Chess</h3>
|
||||
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div id="CloudStorageProject" class="projectContainer">
|
||||
<div class="displayBox diagonal-section-left">
|
||||
<a target="_blank" href="https://media.joshheaps.net">
|
||||
<img id="CloudStorageImage" class="projectImage projectImageLeft" src="/images/CloudStorageDemo.png" title="CloudStorageScreenshot" />
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
|
||||
</a>
|
||||
</div>
|
||||
<div id="CloudStorageText" class="projectTextRight">
|
||||
<h2 id="CloudStorageTitle" class="projectHeaderRight projectHeader">Image Cloud Storage</h2>
|
||||
<p id="CloudStorageDescription" class="projectDescriptionRight projectDescription">
|
||||
My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Compiler</h3>
|
||||
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div id="DemosBox" class="displayBox">
|
||||
<h2 id="DemoHeader">Demos</h2>
|
||||
<article class="project-card">
|
||||
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
|
||||
</a>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Image Cloud Storage</h3>
|
||||
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
|
||||
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo →</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="DemosBox">
|
||||
<h2 class="section-title">Demos</h2>
|
||||
<div id="buttonWrapper">
|
||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
||||
Play Chess
|
||||
</button>
|
||||
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'">
|
||||
Particle Simulator
|
||||
</button>
|
||||
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
|
||||
Memory Lane
|
||||
</button>
|
||||
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
|
||||
Cloud Image Storage
|
||||
</button>
|
||||
<button id="MoreFiller" class="demoButton">
|
||||
More coming soon...
|
||||
</button>
|
||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="BlogBox">
|
||||
<h2 class="section-title">Latest Posts</h2>
|
||||
<div id="blogPostsList">
|
||||
@foreach (var post in Model.LatestPosts)
|
||||
{
|
||||
<div class="blog-teaser-item">
|
||||
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
|
||||
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
|
||||
<p class="blog-teaser-summary">@post.Summary</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<a class="blog-view-all" href="/blog">View all posts →</a>
|
||||
</section>
|
||||
|
||||
<footer id="Contacts">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="mailto:[email protected]">
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
|
||||
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
|
||||
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
|
||||
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
|
||||
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@@ -117,4 +119,4 @@
|
||||
<script src="/js/site.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/opacityUpdateObserver.js"></script>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel() : PageModel
|
||||
public class IndexModel(IBlogService blogService) : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
public List<BlogPost> LatestPosts { get; set; } = [];
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var allPosts = await blogService.GetAllPostsAsync();
|
||||
LatestPosts = allPosts.Take(3).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WatchModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Watch Chess";
|
||||
}
|
||||
|
||||
<div id="watchHeader">
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<fieldset class="enginePicker">
|
||||
<legend>White</legend>
|
||||
<select id="whiteEngine">
|
||||
<option value="custom">Custom (v1)</option>
|
||||
<option value="customLearned" selected>Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="whiteSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Black</legend>
|
||||
<select id="blackEngine">
|
||||
<option value="custom" selected>Custom (v1)</option>
|
||||
<option value="customLearned">Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="blackSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Auto-train games</legend>
|
||||
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||
</div>
|
||||
|
||||
<div id="gamesFeed"></div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WatchModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WeightsModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Learned Weights";
|
||||
}
|
||||
|
||||
<div id="weightsHeader">
|
||||
<h1>Learned Piece-Square Weights</h1>
|
||||
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
|
||||
<div id="weightsControls">
|
||||
<div class="heatLegend">
|
||||
<span>low</span>
|
||||
<span class="legendBar"></span>
|
||||
<span>high</span>
|
||||
</div>
|
||||
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
|
||||
</div>
|
||||
<a id="backToWatch" href="/watch">← Watch / train</a>
|
||||
</div>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Feature weights</h2>
|
||||
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
|
||||
<div id="featureWeights" class="featurePanel"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Midgame tables</h2>
|
||||
<div id="weightsGridMg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Endgame tables</h2>
|
||||
<div id="weightsGridEg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/ChessScripts/Weights.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WeightsModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
@@ -13,12 +12,34 @@ builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddHttpClient("BlogApi", client =>
|
||||
{
|
||||
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddSingleton<IBlogService, BlogService>();
|
||||
builder.Services.AddSingleton<IChessService, ChessService>();
|
||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
|
||||
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||
builder.Services.AddHostedService<AutoTrainingService>();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
@@ -51,7 +72,4 @@ app.MapControllers();
|
||||
|
||||
app.MapHub<ChessHub>("/chessHub");
|
||||
|
||||
// Build particle simulator bundle from ES6 modules
|
||||
ParticleBundler.BuildBundle(app.Environment);
|
||||
|
||||
app.Run();
|
||||
@@ -11,6 +11,7 @@
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
@@ -20,6 +21,7 @@
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||
/// ChessEngine:AutoTrain config flag.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingService(
|
||||
ISelfPlayCoordinator coordinator,
|
||||
AutoTrainingSettings settings,
|
||||
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||
{
|
||||
private const int LearnedSkill = 6;
|
||||
private const int StockfishSkill = 20;
|
||||
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var running = new List<Task>();
|
||||
int started = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
running.RemoveAll(t => t.IsCompleted);
|
||||
|
||||
int desired = settings.GameCount;
|
||||
bool startFailed = false;
|
||||
|
||||
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||
var config = started++ % 2 == 0
|
||||
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||
|
||||
try
|
||||
{
|
||||
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||
running.Add(completion);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||
// persistent failure doesn't spin a tight loop, then try again.
|
||||
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||
startFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (startFailed)
|
||||
await Task.Delay(_restartBackoff, stoppingToken);
|
||||
else if (running.Count > 0)
|
||||
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||
// count increase promptly).
|
||||
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||
else
|
||||
// Pool is empty (count is 0) — just poll for the count to change.
|
||||
await Task.Delay(_pollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingSettings
|
||||
{
|
||||
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||
public const int MaxGames = 16;
|
||||
|
||||
private int _gameCount;
|
||||
|
||||
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||
|
||||
/// <summary>
|
||||
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||
/// </summary>
|
||||
public int GameCount
|
||||
{
|
||||
get => Volatile.Read(ref _gameCount);
|
||||
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||
}
|
||||
|
||||
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
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();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("BlogApi");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetAllPostsAsync()
|
||||
{
|
||||
return await GetCachedAsync("all_posts",
|
||||
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
|
||||
{
|
||||
return await GetCachedAsync($"post_{slug}",
|
||||
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
|
||||
{
|
||||
return await GetCachedAsync($"tag_{tag}",
|
||||
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
|
||||
}
|
||||
|
||||
private async Task<T?> FetchAsync<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(path);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||
{
|
||||
_cache.TryGetValue(key, out var entry);
|
||||
|
||||
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
|
||||
return (T?)entry.Value;
|
||||
|
||||
var result = await factory();
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||
return result;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||
return (T?)entry.Value;
|
||||
}
|
||||
|
||||
_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()
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Blog cache cleared");
|
||||
}
|
||||
|
||||
private record CacheEntry(object Value, DateTime ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>The available chess engine implementations.</summary>
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom,
|
||||
|
||||
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||
CustomLearned
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
public sealed class ChessEngineOptions
|
||||
{
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the learned-weights file. Leave null to default to
|
||||
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
|
||||
/// production set this to a stable, service-writable location OUTSIDE the deploy
|
||||
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
|
||||
/// trained weights survive deploys and avoid deploy-user vs service-user permission
|
||||
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||
/// </summary>
|
||||
public string? WeightsPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true (and outside Development), a background service continuously plays the learned
|
||||
/// engine against Stockfish to train it. Set to false to stop auto-training without a
|
||||
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||
/// </summary>
|
||||
public bool AutoTrain { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||
/// </summary>
|
||||
public int AutoTrainGameCount { get; set; } = 2;
|
||||
}
|
||||
|
||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||
public sealed class ChessEngineFactory(
|
||||
IOptions<ChessEngineOptions> options,
|
||||
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
|
||||
public IChessEngine Create(int skill) => Create(skill, _kind);
|
||||
|
||||
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
|
||||
{
|
||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class ChessEngineHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all the engine needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The prior positions since the last irreversible move (capture/pawn move), as
|
||||
/// completed FEN strings, oldest first and excluding the current position. This is
|
||||
/// the repetition window the engine needs to detect threefold/50-move draws that a
|
||||
/// single FEN can't express.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
|
||||
{
|
||||
int clock = gs.HalfmoveClock;
|
||||
int count = gs.PositionHistory.Count;
|
||||
int start = count - 1 - clock; // PositionHistory ends with the current position
|
||||
|
||||
if (clock <= 0 || start < 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var fens = new List<string>(clock);
|
||||
|
||||
for (int i = start; i < count - 1; i++)
|
||||
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
|
||||
|
||||
return fens;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -22,12 +22,14 @@ public class ChessService : IChessService
|
||||
|
||||
gameState.Pieces.Clear();
|
||||
gameState.MoveHistory.Clear();
|
||||
gameState.SanHistory.Clear();
|
||||
|
||||
gameState.WhiteCanCastleKingside = true;
|
||||
gameState.WhiteCanCastleQueenside = true;
|
||||
gameState.BlackCanCastleKingside = true;
|
||||
gameState.BlackCanCastleQueenside = true;
|
||||
gameState.EnPassantTarget = null;
|
||||
gameState.HalfmoveClock = 0;
|
||||
|
||||
SetupBlackPieces(gameState);
|
||||
SetupWhitePieces(gameState);
|
||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
||||
gameState.CurrentPlayer = PieceColor.White;
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
gameState.PositionHistory.Clear();
|
||||
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||
}
|
||||
|
||||
private static void SetupBlackPieces(GameState gs)
|
||||
@@ -137,12 +142,23 @@ public class ChessService : IChessService
|
||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||
|
||||
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
||||
// the check/mate suffix is appended after UpdateCheckStatus.
|
||||
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
||||
|
||||
PerformMove(gameState, piece, targetPos, moveDto);
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||
gameState.MoveHistory.Add(notation);
|
||||
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||
|
||||
var positionKey = PositionKey(gameState);
|
||||
gameState.PositionHistory.Add(positionKey);
|
||||
|
||||
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
||||
gameState.IsThreefoldRepetition = true;
|
||||
|
||||
return new MoveResultDto
|
||||
{
|
||||
@@ -150,14 +166,95 @@ public class ChessService : IChessService
|
||||
Message = "Move successful.",
|
||||
IsCheck = gameState.IsCheck,
|
||||
IsCheckmate = gameState.IsCheckmate,
|
||||
IsStalemate = gameState.IsStalemate
|
||||
IsStalemate = gameState.IsStalemate,
|
||||
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||
/// applied (the check/mate suffix is added by the caller afterward).
|
||||
/// </summary>
|
||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||
{
|
||||
var from = piece.Position;
|
||||
|
||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||
|
||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||
bool isCapture = targetOccupied || isEnPassant;
|
||||
string dest = SquareName(target);
|
||||
|
||||
if (piece.Type == PieceType.Pawn)
|
||||
{
|
||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||
|
||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||
|
||||
if (promotes)
|
||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||
|
||||
return san;
|
||||
}
|
||||
|
||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||
/// target, qualify the origin by file, else rank, else both.
|
||||
/// </summary>
|
||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||
{
|
||||
var rivals = gs.Pieces
|
||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||
.ToList();
|
||||
|
||||
if (rivals.Count == 0)
|
||||
return "";
|
||||
|
||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||
return FileChar(piece.Position.Col).ToString();
|
||||
|
||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||
return (8 - piece.Position.Row).ToString();
|
||||
|
||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||
}
|
||||
|
||||
private static char FileChar(int col) => (char)('a' + col);
|
||||
|
||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||
|
||||
private static string PieceLetter(PieceType type) => type switch
|
||||
{
|
||||
PieceType.Knight => "N",
|
||||
PieceType.Bishop => "B",
|
||||
PieceType.Rook => "R",
|
||||
PieceType.Queen => "Q",
|
||||
PieceType.King => "K",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||
/// </summary>
|
||||
private static string PositionKey(GameState gs)
|
||||
{
|
||||
var fields = gs.ToFen().Split(' ');
|
||||
return string.Join(' ', fields.Take(4));
|
||||
}
|
||||
|
||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||
{
|
||||
var oldPos = piece.Position;
|
||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
||||
|
||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||
|
||||
@@ -179,6 +276,9 @@ public class ChessService : IChessService
|
||||
|
||||
UpdateCastlingRights(gs, piece, oldPos);
|
||||
|
||||
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
||||
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
||||
|
||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||
? PieceColor.Black
|
||||
: PieceColor.White;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
|
||||
/// through the rules service, and broadcast it to the game's clients.
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IChessService chessService,
|
||||
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||
{
|
||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
|
||||
{
|
||||
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
|
||||
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
|
||||
|
||||
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||
|
||||
return await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
public async Task PlayRandomMoveAsync(GameState state)
|
||||
{
|
||||
var options = chessService.GetAllLegalMoves(state);
|
||||
|
||||
// No legal moves means the game is already over; let the caller's loop detect it.
|
||||
if (options.Count == 0)
|
||||
return;
|
||||
|
||||
var (piece, moves) = options[Random.Shared.Next(options.Count)];
|
||||
var target = moves[Random.Shared.Next(moves.Count)];
|
||||
|
||||
var move = new MoveDto
|
||||
{
|
||||
GameId = state.GameId,
|
||||
PlayerId = CurrentPlayerId(state),
|
||||
PieceId = piece.Id,
|
||||
SourceRow = piece.Position.Row,
|
||||
SourceCol = piece.Position.Col,
|
||||
TargetRow = target.Row,
|
||||
TargetCol = target.Col,
|
||||
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
|
||||
};
|
||||
|
||||
await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
|
||||
{
|
||||
var result = chessService.MakeMove(state, move);
|
||||
|
||||
// Record the played position for training (no-op for non-training games).
|
||||
if (state.Trainer != nint.Zero)
|
||||
weightsStore.Record(state.Trainer, state.ToFen());
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||
|
||||
return (move, result);
|
||||
}
|
||||
|
||||
private static Guid CurrentPlayerId(GameState state) =>
|
||||
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>Which evaluation the native engine uses.</summary>
|
||||
public enum EngineVariant
|
||||
{
|
||||
/// <summary>The hand-crafted evaluation.</summary>
|
||||
Classic,
|
||||
|
||||
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
|
||||
Learned
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
|
||||
/// </summary>
|
||||
public sealed partial class CustomChessEngine : IChessEngine
|
||||
{
|
||||
private readonly EngineSafeHandle _handle;
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||
{
|
||||
Skill = skill;
|
||||
|
||||
// weights= must come last: the native side reads the path as the rest of the
|
||||
// string, which lets it contain ';' and spaces.
|
||||
var options = variant == EngineVariant.Learned
|
||||
? $"skill={skill};variant=learned;weights={weightsPath}"
|
||||
: $"skill={skill}";
|
||||
|
||||
var handle = NativeMethods.engine_create(options);
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||
|
||||
_handle = new EngineSafeHandle(handle);
|
||||
}
|
||||
|
||||
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
|
||||
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
|
||||
|
||||
private unsafe string GetBestMove(string fen, string history)
|
||||
{
|
||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||
byte* buffer = stackalloc byte[bufferLength];
|
||||
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
|
||||
|
||||
if (code != 0)
|
||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||
|
||||
return Marshal.PtrToStringUTF8((IntPtr)buffer)
|
||||
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (added)
|
||||
_handle.DangerousRelease();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handle.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
|
||||
private sealed class EngineSafeHandle : SafeHandle
|
||||
{
|
||||
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
NativeMethods.engine_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
||||
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
||||
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||
/// </summary>
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
static NativeMethods() =>
|
||||
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
|
||||
|
||||
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibName)
|
||||
return IntPtr.Zero;
|
||||
|
||||
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "chess_engine.dll"
|
||||
: "libchess_engine.so";
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
|
||||
|
||||
if (NativeLibrary.TryLoad(path, out var handle))
|
||||
return handle;
|
||||
|
||||
return NativeLibrary.Load(libraryName, assembly, searchPath);
|
||||
}
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr engine_create(string? options);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
|
||||
// ---- Learned-weights / training ABI (see chess_engine.h) ----
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void learned_load(string path);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr trainer_create();
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_record(IntPtr trainer, string fen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_destroy(IntPtr trainer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Concurrent;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
|
||||
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
|
||||
/// </summary>
|
||||
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
|
||||
|
||||
public void Add(GameState game) => _games[game.GameId] = game;
|
||||
|
||||
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
|
||||
|
||||
public bool Contains(Guid id) => _games.ContainsKey(id);
|
||||
|
||||
public IReadOnlyCollection<GameState> All => [.. _games.Values];
|
||||
|
||||
public void ScheduleRemove(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_removalCts.TryRemove(id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_removalCts[id] = cts;
|
||||
|
||||
_removalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cts.Token);
|
||||
|
||||
if (_games.TryGetValue(id, out var game))
|
||||
{
|
||||
if (game.WhiteComputer is not null)
|
||||
await game.WhiteComputer.DisposeAsync();
|
||||
if (game.BlackComputer is not null)
|
||||
await game.BlackComputer.DisposeAsync();
|
||||
|
||||
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
|
||||
if (game.Trainer != nint.Zero)
|
||||
{
|
||||
weightsStore.DestroyTrainer(game.Trainer);
|
||||
game.Trainer = nint.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
_games.Remove(id, out _);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||
_removalCts.TryRemove(id, out _);
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
|
||||
/// On construction it points the native engine at the weights file; everything else delegates
|
||||
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
|
||||
/// </summary>
|
||||
public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
||||
{
|
||||
private const int Pieces = 6; // Pawn..King
|
||||
private const int Squares = 64;
|
||||
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
||||
|
||||
public string WeightsFilePath { get; }
|
||||
|
||||
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
|
||||
{
|
||||
// Prefer the configured path (production points this outside the deploy dir); fall
|
||||
// back to the content root for local dev.
|
||||
var configured = options.Value.WeightsPath;
|
||||
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
|
||||
: configured;
|
||||
|
||||
// A missing/unwritable/misconfigured path must not take down the whole app — the
|
||||
// learned engine just plays from a neutral table and can't persist training.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
|
||||
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
|
||||
"play from a neutral table and training will not persist. In production set " +
|
||||
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
|
||||
WeightsFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
|
||||
|
||||
public void Record(nint trainer, string fen) =>
|
||||
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
|
||||
|
||||
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
|
||||
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
|
||||
|
||||
public void DestroyTrainer(nint trainer) =>
|
||||
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
|
||||
|
||||
public LearnedWeightsSnapshot Snapshot()
|
||||
{
|
||||
const int total = Pieces * Squares * 2 + Features;
|
||||
var buffer = new int[total];
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (int* p = buffer)
|
||||
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
|
||||
}
|
||||
|
||||
var mg = new int[Pieces][];
|
||||
var eg = new int[Pieces][];
|
||||
|
||||
for (int piece = 0; piece < Pieces; piece++)
|
||||
{
|
||||
mg[piece] = new int[Squares];
|
||||
eg[piece] = new int[Squares];
|
||||
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
|
||||
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
|
||||
}
|
||||
|
||||
var features = new int[Features];
|
||||
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
|
||||
|
||||
return new LearnedWeightsSnapshot(mg, eg, features);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class PgnExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||
/// </summary>
|
||||
public static string ToPgn(this GameState gs)
|
||||
{
|
||||
var result = ResultTag(gs);
|
||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||
sb.AppendLine($"[Result \"{result}\"]");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
sb.Append(i / 2 + 1).Append(". ");
|
||||
|
||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||
}
|
||||
|
||||
sb.Append(result);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResultTag(GameState gs)
|
||||
{
|
||||
if (gs.IsCheckmate)
|
||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||
|
||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||
return "1/2-1/2";
|
||||
|
||||
if (gs.IsForfeited)
|
||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||
/// </summary>
|
||||
public sealed class SelfPlayCoordinator(
|
||||
IChessService chessService,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore) : ISelfPlayCoordinator
|
||||
{
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Plies of random legal moves at the start of a training game, so self-play and
|
||||
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||
private const int _openingRandomPlies = 4;
|
||||
|
||||
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||
IChessEngine blackComputer;
|
||||
try
|
||||
{
|
||||
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
throw;
|
||||
}
|
||||
|
||||
var gameState = chessService.CreateNewGame();
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.WhiteEngineKind = config.WhiteKind;
|
||||
gameState.BlackEngineKind = config.BlackKind;
|
||||
gameState.WhiteComputer = whiteComputer;
|
||||
gameState.BlackComputer = blackComputer;
|
||||
|
||||
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||
gameState.Trainer = weightsStore.CreateTrainer();
|
||||
|
||||
gameStore.Add(gameState);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||
return (gameState.GameId, completion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||
/// </summary>
|
||||
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
bool aborted = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Give spectators a moment to join the SignalR group before the first move.
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
|
||||
// Training games open with random moves so they don't replay the same line.
|
||||
if (gameState.Trainer != nint.Zero)
|
||||
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||
{
|
||||
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
|
||||
var lastMoveCount = gameState.MoveHistory.Count;
|
||||
var lastProgress = DateTime.UtcNow;
|
||||
|
||||
while (IsLive(gameState, cancellationToken))
|
||||
{
|
||||
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||
|
||||
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||
{
|
||||
lastMoveCount = gameState.MoveHistory.Count;
|
||||
lastProgress = DateTime.UtcNow;
|
||||
}
|
||||
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||
throw new TimeoutException("no move played within the idle timeout");
|
||||
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||
catch (TimeoutException)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||
}
|
||||
|
||||
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||
// idle past the timeout) records nothing and just frees its trainer.
|
||||
if (aborted)
|
||||
DiscardTraining(gameState);
|
||||
else
|
||||
ApplyLearning(gameState);
|
||||
|
||||
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||
if (gameStore.Contains(gameState.GameId))
|
||||
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||
/// observed so it isn't an unobserved task exception.
|
||||
/// </summary>
|
||||
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
var play = orchestrator.PlayAsync(gameState);
|
||||
try
|
||||
{
|
||||
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||
!cancellationToken.IsCancellationRequested
|
||||
&& gameStore.Contains(gameState.GameId)
|
||||
&& !IsGameOver(gameState);
|
||||
|
||||
private static bool IsGameOver(GameState gameState) =>
|
||||
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||
/// </summary>
|
||||
private void ApplyLearning(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||
/// idle-timed-out game teaches the model nothing.
|
||||
/// </summary>
|
||||
private void DiscardTraining(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||
/// </summary>
|
||||
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||
{
|
||||
winner = PieceColor.White;
|
||||
weight = 1.0;
|
||||
|
||||
if (gameState.IsCheckmate)
|
||||
{
|
||||
// The side to move is the mated one, so the winner is the other color.
|
||||
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||
{
|
||||
var (white, black) = MaterialCounts(gameState);
|
||||
|
||||
if (white == black)
|
||||
return false; // a balanced draw carries no signal
|
||||
|
||||
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||
weight = 0.5;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // forfeit / unfinished
|
||||
}
|
||||
|
||||
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||
{
|
||||
int white = 0, black = 0;
|
||||
|
||||
for (int row = 0; row < 8; row++)
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var piece = gameState.Board[row, col];
|
||||
|
||||
if (piece is null)
|
||||
continue;
|
||||
|
||||
int value = piece.Type switch
|
||||
{
|
||||
PieceType.Pawn => 1,
|
||||
PieceType.Knight => 3,
|
||||
PieceType.Bishop => 3,
|
||||
PieceType.Rook => 5,
|
||||
PieceType.Queen => 9,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
if (piece.Color == PieceColor.White)
|
||||
white += value;
|
||||
else
|
||||
black += value;
|
||||
}
|
||||
|
||||
return (white, black);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class Stockfish : IAsyncDisposable
|
||||
public sealed class Stockfish : IChessEngine
|
||||
{
|
||||
private readonly Process _p;
|
||||
private readonly StreamWriter _stdin;
|
||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||
private readonly int _skill;
|
||||
|
||||
public int Skill => _skill;
|
||||
|
||||
public Stockfish(int skill = 20, int hash = 256)
|
||||
{
|
||||
_skill = skill;
|
||||
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
|
||||
|
||||
string exePath = Path.Combine(baseDir, "Resources", fileName);
|
||||
|
||||
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
|
||||
"Ensure the file is present in the Resources folder of your project.");
|
||||
}
|
||||
|
||||
|
||||
Console.Write(exePath);
|
||||
|
||||
_p = new Process
|
||||
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
WaitFor("readyok").GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<string> GetBestMoveAsync(string fen)
|
||||
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
||||
{
|
||||
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
||||
Send($"position fen {fen}");
|
||||
Send($"go depth {_skill}");
|
||||
string? best = null;
|
||||
@@ -102,152 +100,26 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
if (line == token) break;
|
||||
}
|
||||
|
||||
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
|
||||
// here forever — that would block GameStore's removal task and leave the finished game
|
||||
// stuck in the store, cluttering the watch page indefinitely.
|
||||
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Send("quit");
|
||||
_stdin.Close();
|
||||
await _p.WaitForExitAsync();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
|
||||
{
|
||||
var move = await GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var moveDto = move.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, moveDto);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockfishHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
try
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
if (!_p.HasExited)
|
||||
_p.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(" 0 ").Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
public interface IBlogService
|
||||
{
|
||||
Task<List<BlogPost>> GetAllPostsAsync();
|
||||
Task<BlogPost?> GetPostBySlugAsync(string slug);
|
||||
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
|
||||
void ClearCache();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
|
||||
/// Selects a move for a position; it does not enforce rules or broadcast updates.
|
||||
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
|
||||
/// </summary>
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The engine's playing strength / search depth.</summary>
|
||||
int Skill { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||
/// </summary>
|
||||
/// <param name="fen">The current position as a FEN string.</param>
|
||||
/// <param name="historyFens">
|
||||
/// Prior positions since the last irreversible move, oldest first, excluding the
|
||||
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
|
||||
/// May be empty.
|
||||
/// </param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
|
||||
/// </summary>
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game using the configured default engine.
|
||||
/// The caller owns and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game using an explicitly chosen engine
|
||||
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
|
||||
/// and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <param name="kind">The engine implementation to create.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill, ChessEngineKind kind);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
|
||||
/// the rules service, and broadcasts the result to the game's clients.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the side-to-move's engine pick a move for the current position, applies it, and
|
||||
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a move in.</param>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
|
||||
|
||||
/// <summary>
|
||||
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
|
||||
/// broadcasting it. Used to randomize the opening of training games so self-play and
|
||||
/// engine-vs-engine games don't replay the same line every time.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a random move in.</param>
|
||||
Task PlayRandomMoveAsync(GameState state);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
|
||||
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
|
||||
/// auto-training games), so every game is reachable from one place for lookup and spectating.
|
||||
/// </summary>
|
||||
public interface IGameStore
|
||||
{
|
||||
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||
void Add(GameState game);
|
||||
|
||||
/// <summary>Look a game up by id.</summary>
|
||||
bool TryGet(Guid id, out GameState game);
|
||||
|
||||
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||
bool Contains(Guid id);
|
||||
|
||||
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||
IReadOnlyCollection<GameState> All { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||
/// </summary>
|
||||
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Thin managed facade over the native learned-weights model. The weights, all feature
|
||||
/// computation, per-game accumulation, the update rule, and persistence live in the native
|
||||
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
|
||||
/// the table back for visualization.
|
||||
/// </summary>
|
||||
public interface ILearnedWeightsStore
|
||||
{
|
||||
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
|
||||
string WeightsFilePath { get; }
|
||||
|
||||
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
|
||||
LearnedWeightsSnapshot Snapshot();
|
||||
|
||||
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
|
||||
nint CreateTrainer();
|
||||
|
||||
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
|
||||
void Record(nint trainer, string fen);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a finished game's outcome to the global weights (and saves): rewards the
|
||||
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
|
||||
/// </summary>
|
||||
void ApplyResult(nint trainer, PieceColor winner, double weight);
|
||||
|
||||
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
|
||||
void DestroyTrainer(nint trainer);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||
public sealed record SelfPlayConfig(
|
||||
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||
ChessEngineKind BlackKind, int BlackSkill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
|
||||
/// the learned engine plays) feeding the result back into the learned weights. Used by the
|
||||
/// spectator "watch" endpoint and by the auto-trainer.
|
||||
/// </summary>
|
||||
public interface ISelfPlayCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Create, register, and start running a self-play game. Returns immediately with the new
|
||||
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
|
||||
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
|
||||
/// </summary>
|
||||
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace JoshHeaps.Net.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bundles particle simulator ES6 modules into a single file
|
||||
/// </summary>
|
||||
public static class ParticleBundler
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the particle simulator build script
|
||||
/// </summary>
|
||||
/// <param name="webHostEnvironment">Web host environment for path resolution</param>
|
||||
/// <returns>True if build succeeded, false otherwise</returns>
|
||||
public static bool BuildBundle(IWebHostEnvironment webHostEnvironment)
|
||||
{
|
||||
var particlesPath = Path.Combine(webHostEnvironment.WebRootPath, "js", "particles");
|
||||
var buildScriptPath = Path.Combine(particlesPath, "build.js");
|
||||
|
||||
if (!File.Exists(buildScriptPath))
|
||||
{
|
||||
Console.WriteLine($"⚠️ Particle bundler script not found at: {buildScriptPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("🔨 Building particle simulator bundle...");
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "node",
|
||||
Arguments = $"\"{buildScriptPath}\"",
|
||||
WorkingDirectory = particlesPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(processStartInfo);
|
||||
if (process == null)
|
||||
{
|
||||
Console.WriteLine("❌ Failed to start Node.js process");
|
||||
return false;
|
||||
}
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
Console.WriteLine(output);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("❌ Particle bundle build failed:");
|
||||
Console.WriteLine(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ Error building particle bundle: {ex.Message}");
|
||||
Console.WriteLine(" Make sure Node.js is installed and available in PATH");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"BlogApi": {
|
||||
"BaseUrl": "https://media.joshheaps.net",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-index {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-filter strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-filter a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-filter a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.blog-empty {
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-card:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-card-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-card-title {
|
||||
color: var(--color-heading-secondary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.3rem;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-card-link:hover .blog-card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-card-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-card-summary {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-card-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-back {
|
||||
color: var(--color-text-subtle);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-back:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-post-header {
|
||||
margin: 2.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.blog-post-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Post Content ── */
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text);
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.post-content h1,
|
||||
.post-content h2,
|
||||
.post-content h3,
|
||||
.post-content h4 {
|
||||
color: var(--color-heading-secondary);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.post-content h1 { font-size: 1.6rem; }
|
||||
.post-content h2 { font-size: 1.35rem; }
|
||||
.post-content h3 { font-size: 1.15rem; }
|
||||
|
||||
.post-content p {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.post-content a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-content a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.post-content ul,
|
||||
.post-content ol {
|
||||
margin: 0 0 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.post-content li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.post-content code {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-heading-secondary);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-content pre {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.post-content pre[class*="language-"] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.post-content pre code {
|
||||
background: none;
|
||||
color: var(--prism-text);
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.post-content blockquote {
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.post-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.post-content strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.post-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Prism.js Tomorrow Night theme (customized) */
|
||||
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
|
||||
|
||||
/* ============================================
|
||||
Theme colors — edit these to restyle
|
||||
============================================ */
|
||||
:root {
|
||||
--prism-background: #0a0a0a;
|
||||
--prism-text: #909090;
|
||||
--prism-comment: #555;
|
||||
--prism-punctuation: #707070;
|
||||
--prism-tag: #09ff00;
|
||||
--prism-function: #fff;
|
||||
--prism-boolean: #1ab815;
|
||||
--prism-number: #1ab815;
|
||||
--prism-property: #c8c8c8;
|
||||
--prism-class: #c8c8c8;
|
||||
--prism-keyword: #09ff00;
|
||||
--prism-string: #43c760;
|
||||
--prism-variable: #43c760;
|
||||
--prism-operator: #999;
|
||||
--prism-inserted: #09ff00;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: var(--prism-text);
|
||||
background: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: var(--prism-background);
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--prism-comment);
|
||||
}
|
||||
|
||||
/* Punctuation */
|
||||
.token.punctuation {
|
||||
color: var(--prism-punctuation);
|
||||
}
|
||||
|
||||
/* Tags, attributes, deleted */
|
||||
.token.tag,
|
||||
.token.attr-name,
|
||||
.token.namespace,
|
||||
.token.deleted {
|
||||
color: var(--prism-tag);
|
||||
}
|
||||
|
||||
/* Function names */
|
||||
.token.function-name,
|
||||
.token.function {
|
||||
color: var(--prism-function);
|
||||
}
|
||||
|
||||
/* Booleans */
|
||||
.token.boolean {
|
||||
color: var(--prism-boolean);
|
||||
}
|
||||
|
||||
/* Numbers */
|
||||
.token.number {
|
||||
color: var(--prism-number);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.token.property {
|
||||
color: var(--prism-property);
|
||||
}
|
||||
|
||||
/* Classes, constants, symbols */
|
||||
.token.class-name,
|
||||
.token.constant,
|
||||
.token.symbol {
|
||||
color: var(--prism-class);
|
||||
}
|
||||
|
||||
/* Keywords, selectors, builtins */
|
||||
.token.selector,
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.keyword,
|
||||
.token.builtin {
|
||||
color: var(--prism-keyword);
|
||||
}
|
||||
|
||||
/* Strings, chars, attr values, regex */
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex {
|
||||
color: var(--prism-string);
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
.token.variable {
|
||||
color: var(--prism-variable);
|
||||
}
|
||||
|
||||
/* Operators, entities, URLs */
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--prism-operator);
|
||||
}
|
||||
|
||||
/* Inserted */
|
||||
.token.inserted {
|
||||
color: var(--prism-inserted);
|
||||
}
|
||||
|
||||
/* Formatting */
|
||||
.token.bold,
|
||||
.token.important {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
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;
|
||||
}
|
||||
|
||||
#holdemApp {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
|
||||
#holdemHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.04em;
|
||||
color: #f5f5f5;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
#holdemHeader h1::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 42px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #d8b46a, #a9782f);
|
||||
}
|
||||
|
||||
.holdemSubtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#holdemHeaderActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.holdemBtn {
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #eee;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 7px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.holdemBtn:hover {
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
border-color: #4a4a4a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.holdemBtnAccent {
|
||||
background: linear-gradient(180deg, #16783a, #0d5225);
|
||||
border-color: #1e8449;
|
||||
color: #ecffef;
|
||||
}
|
||||
|
||||
.holdemBtnAccent:hover {
|
||||
background: linear-gradient(180deg, #1a8a44, #0f602c);
|
||||
}
|
||||
|
||||
.holdemBtnDanger {
|
||||
background: linear-gradient(180deg, #4a1f1f, #351515);
|
||||
border-color: #6b2626;
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
.holdemBtnDanger:hover {
|
||||
background: linear-gradient(180deg, #572525, #401818);
|
||||
}
|
||||
|
||||
.holdemBtnGhost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.holdemBtnSmall {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
|
||||
#holdemLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#tableSection {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tableHint {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ---------- Poker table ---------- */
|
||||
|
||||
#pokerTableWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 72%;
|
||||
}
|
||||
|
||||
#pokerTable {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse at 50% 42%, #227a45 0%, #175f37 45%, #0e4527 75%, #082c19 100%);
|
||||
border: 14px solid #6b4225;
|
||||
box-shadow:
|
||||
inset 0 0 0 3px rgba(216, 180, 106, 0.55),
|
||||
inset 0 0 80px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 4px #2c1a0d,
|
||||
0 20px 50px rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.06);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#potBadge {
|
||||
position: relative;
|
||||
background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55));
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1.7rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#potBadgeLabel {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #d8b46a;
|
||||
}
|
||||
|
||||
#potBadgeAmount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#seatsLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* ---------- Seats (compact pucks on the table) ---------- */
|
||||
|
||||
.seat {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 96px;
|
||||
min-height: 54px;
|
||||
background: linear-gradient(160deg, #2c2c2c, #1b1b1b);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 0.3rem 0.4rem;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.seat.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.seat.dropTarget {
|
||||
outline: 2px dashed #d8b46a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.seat.seatInactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.seat.seatFolded {
|
||||
border-style: dashed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.seat.seatSelected {
|
||||
border-color: rgba(9, 255, 0, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.seatChairNumber {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 6px;
|
||||
font-size: 0.58rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.seatPuckName {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #f0f0f0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.seatPuckCards {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: #ddd;
|
||||
min-height: 0.95em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.seatPuckCards .cardRed {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.seatPuckTotal {
|
||||
display: inline-block;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
color: #a9f5b8;
|
||||
background: rgba(9, 255, 0, 0.1);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.35rem;
|
||||
min-height: 1.1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dealerChip {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%);
|
||||
color: #222;
|
||||
border: 2px solid #999;
|
||||
font-weight: 700;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.seat:hover .dealerChip,
|
||||
.dealerChip:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dealerChip.active {
|
||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||
border-color: #a9782f;
|
||||
color: #3a2e00;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dealerChip.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blindBadge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
padding: 0.05rem 0.34rem;
|
||||
border-radius: 999px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.blindBadge.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sbBadge {
|
||||
left: -6px;
|
||||
background: linear-gradient(180deg, #3f82d1, #2b5fa0);
|
||||
color: #eaf3ff;
|
||||
}
|
||||
|
||||
.bbBadge {
|
||||
left: 22px;
|
||||
background: linear-gradient(180deg, #dba13f, #b7791f);
|
||||
color: #fff7e6;
|
||||
}
|
||||
|
||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||
|
||||
.seatEditorPanel {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.seatEditorPanel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
border-bottom: 1px solid rgba(216, 180, 106, 0.35);
|
||||
}
|
||||
|
||||
.seatEditorForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.seatCardToggles {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.inHandToggle,
|
||||
.foldToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seatNameInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
color: #eee;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.seatCards {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.cardInput {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: #fdfdfd;
|
||||
color: #111;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cardInput.cardRed {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.seatContribution {
|
||||
border-top: 1px dashed #3a3a3a;
|
||||
padding-top: 0.4rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.chipCounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.chipCountRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.chipSwatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chipCountInput {
|
||||
width: 34px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.2rem;
|
||||
}
|
||||
|
||||
.amountOverrideRow {
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.amountOverrideInput {
|
||||
width: 70px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.seatTotal {
|
||||
font-size: 0.8rem;
|
||||
color: #d6ffe0;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.customFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.customFieldRow {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.customFieldLabel,
|
||||
.customFieldValue {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.removeFieldBtn,
|
||||
.removeChipColorBtn {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
|
||||
#holdemSidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebarPanel {
|
||||
background: linear-gradient(160deg, #212121, #1a1a1a);
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 0.2rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sidebarPanel summary {
|
||||
cursor: pointer;
|
||||
padding: 0.7rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #eee;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
padding-bottom: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.fieldRow input,
|
||||
.fieldRow select {
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
color: #eee;
|
||||
padding: 0.35rem 0.5rem;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#chipLegendList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chipLegendRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chipColorInput {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chipValueInput {
|
||||
flex: 1;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
color: #eee;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
#tableNotes {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
color: #eee;
|
||||
padding: 0.5rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---------- Pot wizard ---------- */
|
||||
|
||||
#potWizardOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#potWizardOverlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#potWizardModal {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.2rem 1.4rem 1.6rem;
|
||||
}
|
||||
|
||||
.potWizardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.potWizardHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.potWizardLog {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.potWizardLogLine {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.potWizardPrompt {
|
||||
font-size: 0.95rem;
|
||||
color: #eee;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.potWizardChoices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.potWizardChoice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #141414;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.potWizardChoice:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.potWizardSummary ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.potWizardSummary li {
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px dashed #333;
|
||||
font-size: 0.9rem;
|
||||
color: #d6ffe0;
|
||||
}
|
||||
|
||||
.potWizardActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#holdemLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#pokerTableWrap {
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.seat {
|
||||
width: 78px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
#boardContainer {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 2vw auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
#chessBoard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
@@ -165,6 +157,47 @@
|
||||
pointer-events: none; /* ensures img doesn't steal the click */
|
||||
}
|
||||
|
||||
#colorModal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #1e1e1e;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#colorModal p {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#colorButtonContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#colorButtonContainer button {
|
||||
background-color: #2c2c2c;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#colorButtonContainer button:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.chessSquare .coordinate-label {
|
||||
position: absolute;
|
||||
font-size: 1.2vw;
|
||||
|
||||
@@ -1,137 +1,429 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
cursor: default;
|
||||
:root {
|
||||
--bg: #2b2c30;
|
||||
--panel: #21232a;
|
||||
--panel-row: rgba(255, 255, 255, 0.035);
|
||||
--bar: #1e2026;
|
||||
--accent: #8cd5ed;
|
||||
--accent-ink: #10222a;
|
||||
--text: #d6d6d6;
|
||||
--muted: #8b8f99;
|
||||
--line: #34373f;
|
||||
--bar-h: clamp(34px, 5.5vh, 50px);
|
||||
--radius: 12px;
|
||||
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
/* The chess page is a fixed, single-screen app: never scroll. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
}
|
||||
/* ---- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Portrait / narrow: board on top, panel stacked below. */
|
||||
#chessContainer {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 5vw;
|
||||
gap: clamp(8px, 1.6vh, 16px);
|
||||
padding: clamp(10px, 2vh, 20px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
width: min(94vw, 56vh);
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 90vw; /* use the smaller of width or height */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 90vw;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
align-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
#gamePanel {
|
||||
width: min(94vw, 56vh);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
flex-direction: column;
|
||||
order: -1;
|
||||
flex-shrink: 1;
|
||||
font-size: large;
|
||||
margin: 5vw;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
flex-grow: 3;
|
||||
}
|
||||
|
||||
/* Put UI to left/right when screen is short */
|
||||
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||
@media (min-aspect-ratio: 1/1) {
|
||||
#chessContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto, auto;
|
||||
grid-template-rows: auto, auto;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
gap: clamp(16px, 3vw, 48px);
|
||||
padding: clamp(12px, 3vh, 28px);
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: min(5vw, 5vh);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
padding: 1vw;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
padding: 1vw;
|
||||
margin: 1vw;
|
||||
font-size: large;
|
||||
#boardArea {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 1;
|
||||
width: min(90vh, 90vw);
|
||||
max-height: 90vh;
|
||||
width: min(72vh, 54vw);
|
||||
height: min(72vh, 54vw);
|
||||
}
|
||||
|
||||
#difficultyButtonContainer {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
.playerBar {
|
||||
width: min(72vh, 54vw);
|
||||
}
|
||||
}
|
||||
|
||||
#gamePanel {
|
||||
width: clamp(300px, 26vw, 380px);
|
||||
height: min(86vh, 100%);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Player bars ------------------------------------------------------- */
|
||||
|
||||
.playerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: var(--bar-h);
|
||||
padding: 0 12px;
|
||||
background: var(--bar);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.playerDot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerDot.white {
|
||||
background: #ededed;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
|
||||
}
|
||||
|
||||
.playerDot.black {
|
||||
background: #2c2c2c;
|
||||
box-shadow: 0 0 0 1px #565656 inset;
|
||||
}
|
||||
|
||||
.playerName {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capturedTray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capturedPiece {
|
||||
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
|
||||
width: auto;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
.advantage {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Game panel -------------------------------------------------------- */
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelLogo {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panelHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#moveList {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.movePlaceholder {
|
||||
margin: 0;
|
||||
padding: 18px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.moveRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4em 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.moveRow:nth-child(odd) {
|
||||
background: var(--panel-row);
|
||||
}
|
||||
|
||||
.moveNum {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.moveSan {
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.moveSan.latest {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#statusLine {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#statusLine.alert {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#panelButtons {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: clamp(0.92rem, 1vw, 1.05rem);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: clamp(10px, 1.4vh, 14px) 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #363a44;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #424752;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: #2a2e37;
|
||||
}
|
||||
|
||||
#watchLink {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#watchLink:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
|
||||
|
||||
.iconBtn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
|
||||
#mobileBar,
|
||||
#menuBackdrop,
|
||||
#menuClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Phone layout ------------------------------------------------------ */
|
||||
/* The full panel doesn't fit alongside a usable board on a phone, so we show
|
||||
the board + a slim status/menu bar, and tuck the move list and secondary
|
||||
actions into a slide-up sheet. The page itself still never scrolls. */
|
||||
@media (max-width: 640px) {
|
||||
#chessContainer {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerBar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
|
||||
#gamePanel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 85dvh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
z-index: 60;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body.menu-open #gamePanel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The slim bar already shows status, so hide the sheet's inline copy. */
|
||||
#gamePanel #statusLine {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
body.menu-open #menuBackdrop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mobileBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#mobileStatus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#mobileStatus.alert {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#mobileBar #menuToggle {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchHeader {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 0;
|
||||
}
|
||||
|
||||
#watchHeader h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchStatus {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
#watchControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.enginePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
.enginePicker legend {
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
#watchControls select {
|
||||
background-color: #1e1e1e;
|
||||
color: #d6d6d6;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
#startCpuVsCpu {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#startCpuVsCpu:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToPlay {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#backToPlay:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#gamesFeed {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gameCard {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameResult {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #8cd5ed;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copyPgnBtn {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyPgnBtn:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.fullscreen-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
background-color: #2b2c30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .miniBoard {
|
||||
width: min(90vh, 90vw);
|
||||
height: min(90vh, 90vw);
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .backButton {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameCardHeader {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.miniBoard {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #141414;
|
||||
color: #d6d6d6;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#weightsHeader {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
#weightsHeader h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
#weightsStatus {
|
||||
color: #9a9a9a;
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
#weightsControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.heatLegend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.legendBar {
|
||||
width: 120px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
|
||||
}
|
||||
|
||||
#refreshWeights {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#refreshWeights:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToWatch {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.weightsSection {
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.weightsSection h2 {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
text-align: center;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.weightsGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.featurePanel {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.featureRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.featureLabel {
|
||||
flex: 0 0 6.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #c8c8c8;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.featureTrack {
|
||||
flex: 1 1 auto;
|
||||
height: 14px;
|
||||
background-color: #232323;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featureBar {
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.featureValue {
|
||||
flex: 0 0 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #e8e8e8;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weightBoard {
|
||||
background-color: #1c1c1c;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.weightBoardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weightBoardIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.weightRange {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.miniHeat {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1rem repeat(8, 34px);
|
||||
}
|
||||
|
||||
.heatSquare {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
color: #f0f0f0;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.heatLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
font-size: 0.6rem;
|
||||
color: #777;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* ── Blog Section ── */
|
||||
|
||||
#BlogBox {
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#blogPostsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-teaser-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-item:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-teaser-title {
|
||||
font-size: clamp(1rem, 1.5vw, 1.15rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-title:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-teaser-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.blog-teaser-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-view-all {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-view-all:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
#Contacts {
|
||||
margin-top: 20vw;
|
||||
/* ── Footer / Contacts ── */
|
||||
|
||||
#Contacts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
height: auto;
|
||||
width: 3.5vw;
|
||||
margin-bottom: 5vw;
|
||||
margin-left: 3vw;
|
||||
fill: rgb(212, 212, 212);
|
||||
transition: fill 0.5s;
|
||||
width: clamp(1.25rem, 2.5vw, 1.75rem);
|
||||
fill: var(--color-text-subtle);
|
||||
transition: fill 0.2s;
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
fill: white;
|
||||
transition: fill 0.5s;
|
||||
}
|
||||
.social-link:hover .social-icon {
|
||||
fill: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.diagonal-section-left {
|
||||
position: absolute;
|
||||
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-left:has(.projectImage:hover) {
|
||||
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageLeft {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
}
|
||||
|
||||
.projectTextRight {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 50vw;
|
||||
}
|
||||
|
||||
.projectHeaderRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
}
|
||||
|
||||
.projectDescriptionRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
.diagonal-section-right {
|
||||
position: absolute;
|
||||
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-right:has(.projectImage:hover) {
|
||||
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageRight {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.projectTextLeft {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.projectHeaderLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
|
||||
.projectDescriptionLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
@@ -1,29 +1,90 @@
|
||||
#ProjectsBox {
|
||||
/* ── Projects Section ── */
|
||||
|
||||
#ProjectsBox {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 20vw;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.projectContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* ── Card ── */
|
||||
|
||||
.project-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
transition: border-color 0.25s, transform 0.25s;
|
||||
}
|
||||
|
||||
.displayBox {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
.project-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
a.projectContainer {
|
||||
text-decoration: none;
|
||||
/* ── Image ── */
|
||||
|
||||
.project-image-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projectImage {
|
||||
transition: width 0.5s ease;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.projectImage:hover {
|
||||
width: 40vw;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.project-card:hover .projectImage {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* ── Info ── */
|
||||
|
||||
.project-info {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.project-link {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,134 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
background-color: #2b2c30;
|
||||
color: #09ff00;
|
||||
overflow-x: hidden;
|
||||
cursor: default;
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
|
||||
#Header {
|
||||
margin-top: 2vw;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.headerOption {
|
||||
font: bold 2vw arial;
|
||||
display: inline;
|
||||
margin-left: 10vw;
|
||||
.site-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-subtle);
|
||||
cursor: pointer;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
transition: color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.headerOption:hover {
|
||||
font: bold 2.3vw arial;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
.nav-link:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Hero / Welcome Message ── */
|
||||
|
||||
#Hero {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
|
||||
}
|
||||
|
||||
#WelcomeMessage {
|
||||
font: 3vw arial;
|
||||
width: auto;
|
||||
height: 40vw;
|
||||
margin-top: 15vw;
|
||||
text-align: left;
|
||||
padding-left: 10vw;
|
||||
padding-right: 10vw;
|
||||
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
|
||||
max-width: 720px;
|
||||
min-height: clamp(12rem, 28vw, 20rem);
|
||||
line-height: 1.7;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Section Titles ── */
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Demos ── */
|
||||
|
||||
#DemosBox {
|
||||
margin: 10vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
width: 80%;
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#DemoHeader {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
#buttonWrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.demoButton {
|
||||
margin-top: 5vw;
|
||||
margin-right: 2vw;
|
||||
padding-top: 0.5vw;
|
||||
padding-bottom: 0.5vw;
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-radius: 5vw;
|
||||
width: 20vw;
|
||||
height: 8vw;
|
||||
font-size: 2vw;
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.demoButton:hover {
|
||||
background: var(--color-surface-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
#MoreFiller {
|
||||
cursor: default;
|
||||
}
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
#MoreFiller:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#Header {
|
||||
padding: 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,56 @@
|
||||
/* ========================================
|
||||
CSS CUSTOM PROPERTIES
|
||||
======================================== */
|
||||
:root {
|
||||
/* Spacing system */
|
||||
--spacing-xs: 0.35rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 0.6rem;
|
||||
--spacing-lg: 0.75rem;
|
||||
--spacing-xl: 1rem;
|
||||
|
||||
/* Layout constants */
|
||||
--fab-size: 44px;
|
||||
--panel-gap: 12px;
|
||||
--border-radius: 10px;
|
||||
--border-radius-sm: 0.4rem;
|
||||
--border-radius-fab: 8px;
|
||||
|
||||
/* Colors */
|
||||
--bg-primary: #000;
|
||||
--bg-panel: rgba(15, 15, 15, 0.65);
|
||||
--bg-panel-dark: rgba(11, 11, 11, 0.65);
|
||||
--bg-button: #1f1f1f;
|
||||
--bg-button-hover: #2a2a2a;
|
||||
--text-primary: #e6e6e6;
|
||||
--border-color: #2a2a2a;
|
||||
--border-color-dark: #1a1a1a;
|
||||
|
||||
/* Slider colors */
|
||||
--slider-track: #444;
|
||||
--slider-fill: #4cafef;
|
||||
--slider-thumb: #ff5722;
|
||||
|
||||
/* Z-index layers */
|
||||
--z-canvas: 0;
|
||||
--z-ui: 20;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
BASE STYLES
|
||||
======================================== */
|
||||
/* ------- page background + overlays ------- */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
background: #000;
|
||||
color: #e6e6e6;
|
||||
font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CANVAS
|
||||
======================================== */
|
||||
/* canvas as full-screen background */
|
||||
.sim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
inset: 0; /* top:0 right:0 bottom:0 left:0 */
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
z-index: var(--z-canvas);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TOOLBAR
|
||||
======================================== */
|
||||
/* floating toolbar */
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: var(--panel-gap);
|
||||
left: var(--panel-gap);
|
||||
right: var(--panel-gap);
|
||||
z-index: var(--z-ui);
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: .6rem .75rem;
|
||||
background: rgba(15,15,15,.65);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* collapse/expand toolbar */
|
||||
.toolbar .spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
#toggleToolbar {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: .35rem .5rem;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
|
||||
/* Collapsed toolbar state (desktop) */
|
||||
.toolbar.collapsed {
|
||||
left: var(--panel-gap);
|
||||
right: auto;
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: .35rem .5rem;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.toolbar.collapsed .group,
|
||||
@@ -115,150 +60,93 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar.collapsed #toggleToolbar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius-fab);
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.toolbar.collapsed #toggleToolbar::before {
|
||||
content: "☰";
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
RULE EDITOR PANEL
|
||||
======================================== */
|
||||
/* floating rules panel (details) */
|
||||
.rulepanel {
|
||||
position: fixed;
|
||||
left: var(--panel-gap);
|
||||
right: var(--panel-gap);
|
||||
bottom: var(--panel-gap);
|
||||
z-index: var(--z-ui);
|
||||
background: var(--bg-panel-dark);
|
||||
border: 1px solid var(--border-color-dark);
|
||||
border-radius: var(--border-radius);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 20;
|
||||
background: rgba(11,11,11,.65);
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(6px);
|
||||
max-height: 42vh;
|
||||
overflow: auto;
|
||||
overflow: hidden; /* collapsed state shows only summary */
|
||||
max-height: 42vh; /* when open, content scrolls inside */
|
||||
}
|
||||
|
||||
.rulepanel[open] {
|
||||
max-height: calc(100dvh - 24px - 60px);
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.rulepanel[open]::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.rulepanel > summary {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--border-color-dark);
|
||||
padding: .6rem .75rem;
|
||||
margin: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
gap: .5rem;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.rulepanel > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rulepanel[open] > summary::after {
|
||||
.rulepanel > summary::after {
|
||||
content: "▾";
|
||||
margin-left: auto;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Collapsed rules panel (FAB style) */
|
||||
.rulepanel:not([open]) {
|
||||
left: var(--panel-gap);
|
||||
right: auto;
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.rulepanel:not([open]) > summary {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.rulepanel:not([open]) > summary::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.rulepanel:not([open]) > summary::before {
|
||||
content: "Rules";
|
||||
font-size: 18px;
|
||||
content: "▸";
|
||||
}
|
||||
|
||||
.rules-body {
|
||||
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-xl);
|
||||
padding: .75rem 1rem 1rem;
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--spacing-sm) var(--spacing-xl);
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px,1fr));
|
||||
gap: .5rem 1rem;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rule-import {
|
||||
display: grid;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FORM CONTROLS
|
||||
======================================== */
|
||||
/* buttons + inputs (keep your existing styles, just a few tweaks) */
|
||||
button {
|
||||
background: var(--bg-button);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--spacing-sm);
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
padding: .4rem .7rem;
|
||||
border-radius: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--bg-button-hover);
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
/* range styling (from your sheet; leave as-is) */
|
||||
:root {
|
||||
--slider-track: #444;
|
||||
--slider-fill: #4cafef;
|
||||
--slider-thumb: #ff5722;
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@@ -305,51 +193,187 @@ input[type="range"]::-moz-range-progress {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MOBILE STYLES
|
||||
======================================== */
|
||||
/* container no longer controls layout; keep for semantics */
|
||||
.container {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Canvas already full screen; nothing to change there */
|
||||
|
||||
/* --- Toolbar square when collapsed --- */
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
background: rgba(15,15,15,.65);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.toolbar.collapsed {
|
||||
left: 12px;
|
||||
right: auto; /* stop spanning full width */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.toolbar.collapsed .group,
|
||||
.toolbar.collapsed #restart,
|
||||
.toolbar.collapsed #permalink,
|
||||
.toolbar.collapsed .spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar.collapsed #toggleToolbar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0; /* hide the word "Toolbar" */
|
||||
}
|
||||
|
||||
.toolbar.collapsed #toggleToolbar::before {
|
||||
content: "☰"; /* icon only */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* --- Rules panel as a floating card; tiny square when closed --- */
|
||||
.rulepanel {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 20;
|
||||
background: rgba(11,11,11,.65);
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(6px);
|
||||
max-height: 42vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rulepanel[open] {
|
||||
max-height: calc(100dvh - 24px - 60px);
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable both-edges; /* avoids layout jump */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.rulepanel[open] {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
/* Firefox */
|
||||
.rulepanel[open]::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
/* WebKit */
|
||||
|
||||
.rulepanel > summary {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: .6rem .75rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.rulepanel > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rulepanel[open] > summary::after {
|
||||
content: "▾";
|
||||
margin-left: auto;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
/* closed => tiny square pinned bottom-right */
|
||||
.rulepanel:not([open]) {
|
||||
left: 12px;
|
||||
right: auto; /* stop spanning full width */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.rulepanel:not([open]) > summary {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0; /* hide label text */
|
||||
}
|
||||
|
||||
.rulepanel:not([open]) > summary::after {
|
||||
content: "";
|
||||
}
|
||||
/* no caret */
|
||||
.rulepanel:not([open]) > summary::before {
|
||||
content: "Rules"; /* icon only */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Keep sliders/layout rules you already have */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Toolbar defaults to FAB on mobile */
|
||||
/* Default to FAB */
|
||||
.toolbar {
|
||||
top: var(--panel-gap);
|
||||
left: var(--panel-gap);
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.toolbar .group,
|
||||
.toolbar #restart,
|
||||
.toolbar #permalink,
|
||||
.toolbar .spacer {
|
||||
.toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* FAB button icon */
|
||||
#toggleToolbar {
|
||||
width: 100%;
|
||||
height: var(--fab-size);
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius-fab);
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#toggleToolbar::before {
|
||||
width: var(--fab-size);
|
||||
width: 44px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Expanded drawer state on mobile */
|
||||
/* Expanded drawer */
|
||||
.toolbar.mobile.open {
|
||||
left: var(--panel-gap);
|
||||
left: 12px;
|
||||
width: min(85vw, 420px);
|
||||
height: 70vh;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
height: 70vh; /* scroll within drawer if needed */
|
||||
padding: .6rem .75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
gap: .65rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -361,7 +385,7 @@ input[type="range"]::-moz-range-progress {
|
||||
|
||||
.toolbar.mobile.open .group {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.toolbar.mobile.open input[type="number"],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--color-bg: #0a0a0a;
|
||||
--color-surface: rgba(255, 255, 255, 0.02);
|
||||
--color-surface-hover: rgba(255, 255, 255, 0.04);
|
||||
--color-surface-raised: rgba(255, 255, 255, 0.05);
|
||||
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text */
|
||||
--color-heading: #fff;
|
||||
--color-heading-secondary: #e0e0e0;
|
||||
--color-text: #b0b0b0;
|
||||
--color-text-muted: #aaa;
|
||||
--color-text-subtle: #777;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #09ff00;
|
||||
--color-accent-hover: #5fff5f;
|
||||
|
||||
/* Borders */
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-border-hover: rgba(255, 255, 255, 0.15);
|
||||
--color-accent-border: rgba(9, 255, 0, 0.4);
|
||||
|
||||
/* Typography */
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createCPUGame(difficulty) {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||
async createCPUGame(difficulty, color = "random") {
|
||||
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
@@ -14,6 +14,20 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async getPgn(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (!response.ok) throw new Error("PGN unavailable");
|
||||
return await response.text();
|
||||
},
|
||||
|
||||
async forfeit(gameId, playerId) {
|
||||
await fetch("/api/chess/forfeit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
|
||||
});
|
||||
},
|
||||
|
||||
async getLegalMoves(pieceId) {
|
||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||
if (!response.ok) throw new Error("API failed");
|
||||
@@ -32,13 +46,15 @@ const ChessAPI = {
|
||||
throw new Error(message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// The move endpoint returns both the move result and the full resulting
|
||||
// board state, so the caller can render without a follow-up fetch.
|
||||
const data = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Invalid move or not your turn.");
|
||||
if (!data.result.success) {
|
||||
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
return result;
|
||||
return data;
|
||||
},
|
||||
|
||||
async handleMove(targetRow, targetCol) {
|
||||
@@ -71,16 +87,14 @@ const ChessAPI = {
|
||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||
|
||||
try {
|
||||
const moveResult = await this.makeMove(moveDto);
|
||||
const { result, state } = await this.makeMove(moveDto);
|
||||
|
||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||
ChessBoard.renderPieces(updatedGame.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
GameState.clearSelection();
|
||||
ChessInteractions.clearHighlights();
|
||||
|
||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||
this.alertGameStatusChange(moveResult);
|
||||
this.alertGameStatusChange(result);
|
||||
|
||||
} catch (error) {
|
||||
alert("❌ " + error.message);
|
||||
@@ -101,9 +115,13 @@ const ChessAPI = {
|
||||
} else if (moveResult.isStalemate) {
|
||||
alert("🤝 Stalemate!");
|
||||
gameOver = true;
|
||||
} else if (moveResult.isThreefoldRepetition) {
|
||||
alert("🤝 Draw by threefold repetition!");
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
showCopyPgn(GameState.currentGameId);
|
||||
await ChessSignalR.leaveGame();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -1,4 +1,91 @@
|
||||
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
||||
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
||||
|
||||
const ChessBoard = {
|
||||
// Render from a full game-state payload (the shape returned by the move/state
|
||||
// endpoints and pushed over SignalR). Ignores state older than what's already
|
||||
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
||||
renderState(state) {
|
||||
if (!state || !GameState.shouldApply(state.version)) return;
|
||||
this.renderPieces(state.pieces);
|
||||
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
||||
this.renderMoveList(state.sanHistory);
|
||||
this.renderStatus(state);
|
||||
GameState.setVersion(state.version);
|
||||
},
|
||||
|
||||
// Two-column numbered move list (white move, black move), latest highlighted.
|
||||
renderMoveList(sanHistory) {
|
||||
const list = document.getElementById("moveList");
|
||||
if (!list) return;
|
||||
|
||||
const san = sanHistory ?? [];
|
||||
|
||||
if (san.length === 0) {
|
||||
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
for (let i = 0; i < san.length; i += 2) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "moveRow";
|
||||
|
||||
const num = document.createElement("span");
|
||||
num.className = "moveNum";
|
||||
num.textContent = `${i / 2 + 1}.`;
|
||||
row.appendChild(num);
|
||||
|
||||
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
||||
|
||||
if (i + 1 < san.length)
|
||||
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
||||
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
},
|
||||
|
||||
moveCell(san, isLatest) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
||||
cell.textContent = san;
|
||||
return cell;
|
||||
},
|
||||
|
||||
renderStatus(state) {
|
||||
let text;
|
||||
let alert = false;
|
||||
|
||||
if (state.isCheckmate) {
|
||||
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
||||
text = `Checkmate — ${winner} wins`;
|
||||
alert = true;
|
||||
} else if (state.isStalemate) {
|
||||
text = "Draw — stalemate";
|
||||
alert = true;
|
||||
} else if (state.isThreefoldRepetition) {
|
||||
text = "Draw — threefold repetition";
|
||||
alert = true;
|
||||
} else {
|
||||
text = `${state.currentPlayer} to move`;
|
||||
if (state.isCheck) {
|
||||
text += " — check";
|
||||
alert = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror to both the desktop panel status and the mobile bar status.
|
||||
["statusLine", "mobileStatus"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle("alert", alert);
|
||||
});
|
||||
},
|
||||
|
||||
renderPieces(pieces) {
|
||||
this.clearAllSquares();
|
||||
this.renderCoordinateLabels();
|
||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
||||
this.highlightPreviousMove();
|
||||
},
|
||||
|
||||
// Show each side's captured pieces and the leading side's material advantage,
|
||||
// arranged so the current player's tray sits below the board.
|
||||
renderCapturedTrays(activePieces, capturedPieces) {
|
||||
const captured = capturedPieces ?? [];
|
||||
|
||||
// A captured piece's color is the side that lost it, so White's haul is the
|
||||
// captured Black pieces, and vice versa.
|
||||
const whiteCaptured = captured.filter(p => p.color === 1);
|
||||
const blackCaptured = captured.filter(p => p.color === 0);
|
||||
|
||||
// Net material from pieces still on the board, so promotions count correctly.
|
||||
const advantage = (activePieces ?? []).reduce(
|
||||
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
||||
|
||||
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
||||
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
||||
|
||||
const isWhite = GameState.currentPlayerIsWhite !== false;
|
||||
this.fillCapturedTray("bottom", isWhite ? white : black);
|
||||
this.fillCapturedTray("top", isWhite ? black : white);
|
||||
},
|
||||
|
||||
fillCapturedTray(position, side) {
|
||||
const tray = document.getElementById(`captured-${position}`);
|
||||
const badge = document.getElementById(`advantage-${position}`);
|
||||
if (!tray || !badge) return;
|
||||
|
||||
tray.innerHTML = "";
|
||||
[...side.captured]
|
||||
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
||||
.forEach(piece => {
|
||||
const img = document.createElement("img");
|
||||
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "capturedPiece";
|
||||
img.draggable = false;
|
||||
tray.appendChild(img);
|
||||
});
|
||||
|
||||
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
||||
},
|
||||
|
||||
clearAllSquares() {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`square-${i}`);
|
||||
|
||||
@@ -23,6 +23,16 @@ const ChessModals = {
|
||||
resolve(difficulty);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
promptColor() {
|
||||
return new Promise(resolve => {
|
||||
document.getElementById("colorModal").style.display = "block";
|
||||
window.selectColor = (color) => {
|
||||
document.getElementById("colorModal").style.display = "none";
|
||||
resolve(color);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,12 @@ const ChessSignalR = {
|
||||
console.error("❌ SignalR connection closed:", err?.message);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||
await this.handleGameOver(gameId, winner, reason);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -24,20 +28,28 @@ const ChessSignalR = {
|
||||
}
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameId);
|
||||
// Drop the echo of our own move and any out-of-order delivery.
|
||||
if (!GameState.shouldApply(state?.version)) return;
|
||||
|
||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(state);
|
||||
|
||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
}
|
||||
async handleGameOver(gameId, winner, reason) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
|
||||
|
||||
if (reason === "forfeit")
|
||||
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
||||
|
||||
showCopyPgn(gameId);
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async leaveGame() {
|
||||
|
||||
@@ -6,6 +6,10 @@ const GameState = {
|
||||
legalMoves: [],
|
||||
previousMoveStart: null,
|
||||
previousMoveEnd: null,
|
||||
// Highest ply (move count) already rendered. Lets us ignore stale or
|
||||
// already-applied updates that arrive out of order, including the echo
|
||||
// of our own move.
|
||||
lastVersion: -1,
|
||||
|
||||
reset() {
|
||||
this.currentGameId = null;
|
||||
@@ -15,12 +19,22 @@ const GameState = {
|
||||
this.legalMoves = [];
|
||||
this.previousMoveStart = null;
|
||||
this.previousMoveEnd = null;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
shouldApply(version) {
|
||||
return typeof version !== "number" || version > this.lastVersion;
|
||||
},
|
||||
|
||||
setVersion(version) {
|
||||
if (typeof version === "number") this.lastVersion = version;
|
||||
},
|
||||
|
||||
setGameInfo(gameId, playerId, isWhite) {
|
||||
this.currentGameId = gameId;
|
||||
this.currentPlayerId = playerId;
|
||||
this.currentPlayerIsWhite = isWhite;
|
||||
this.lastVersion = -1;
|
||||
},
|
||||
|
||||
setSelectedPiece(piece) {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
const Spectate = {
|
||||
connection: null,
|
||||
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
||||
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
||||
|
||||
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
||||
|
||||
async init() {
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/chessHub")
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
||||
this.handleMoveUpdate(gameId, moveDto, state));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
try {
|
||||
await this.connection.start();
|
||||
} catch (err) {
|
||||
console.error("❌ SignalR failed to start:", err);
|
||||
}
|
||||
|
||||
await this.refreshGames();
|
||||
await this.loadAutoTrainCount();
|
||||
setInterval(() => this.refreshGames(), 5000);
|
||||
},
|
||||
|
||||
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||
async loadAutoTrainCount() {
|
||||
const input = document.getElementById("autoTrainCount");
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/autotrain");
|
||||
const data = await response.json();
|
||||
input.max = data.max;
|
||||
// Don't clobber the value while the user is editing it.
|
||||
if (document.activeElement !== input)
|
||||
input.value = data.count;
|
||||
} catch {
|
||||
// Leave the control as-is if auto-training status can't be read.
|
||||
}
|
||||
},
|
||||
|
||||
async setAutoTrainCount() {
|
||||
const input = document.getElementById("autoTrainCount");
|
||||
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
||||
const data = await response.json();
|
||||
input.value = data.count;
|
||||
} catch (err) {
|
||||
console.error("❌ Could not set the auto-training game count.", err);
|
||||
}
|
||||
},
|
||||
|
||||
async startCpuGame() {
|
||||
const params = new URLSearchParams({
|
||||
whiteEngine: document.getElementById("whiteEngine").value,
|
||||
whiteSkill: document.getElementById("whiteSkill").value,
|
||||
blackEngine: document.getElementById("blackEngine").value,
|
||||
blackSkill: document.getElementById("blackSkill").value
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch(`/api/chess/watch/cpu?${params}`);
|
||||
await this.refreshGames();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshGames() {
|
||||
let games;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/active");
|
||||
games = await response.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = new Set(games.map(g => g.gameId));
|
||||
|
||||
for (const gameId of [...this.games.keys()])
|
||||
if (!activeIds.has(gameId))
|
||||
await this.removeGame(gameId);
|
||||
|
||||
for (const game of games)
|
||||
if (this.games.has(game.gameId))
|
||||
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
||||
else
|
||||
await this.addGame(game);
|
||||
|
||||
const status = document.getElementById("watchStatus");
|
||||
status.textContent = games.length === 0
|
||||
? "No games are being played right now."
|
||||
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
||||
},
|
||||
|
||||
async addGame(game) {
|
||||
this.games.set(game.gameId, {
|
||||
isVsComputer: game.isVsComputer,
|
||||
isComputerVsComputer: game.isComputerVsComputer,
|
||||
whiteEngine: game.whiteEngine,
|
||||
blackEngine: game.blackEngine
|
||||
});
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "gameCard";
|
||||
card.id = `card-${game.gameId}`;
|
||||
card.onclick = () => this.enterFullscreen(game.gameId);
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "backButton";
|
||||
back.textContent = "← Back";
|
||||
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
||||
card.appendChild(back);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "gameCardHeader";
|
||||
header.id = `header-${game.gameId}`;
|
||||
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
||||
card.appendChild(header);
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "miniBoard";
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.createElement("div");
|
||||
square.id = `sq-${game.gameId}-${i}`;
|
||||
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
||||
board.appendChild(square);
|
||||
}
|
||||
|
||||
card.appendChild(board);
|
||||
document.getElementById("gamesFeed").appendChild(card);
|
||||
|
||||
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
||||
await this.renderGame(game.gameId);
|
||||
},
|
||||
|
||||
async removeGame(gameId) {
|
||||
this.games.delete(gameId);
|
||||
this.pgns.delete(gameId);
|
||||
document.getElementById(`card-${gameId}`)?.remove();
|
||||
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
||||
},
|
||||
|
||||
async renderGame(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
this.renderFromState(gameId, await response.json());
|
||||
},
|
||||
|
||||
renderFromState(gameId, state) {
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
if (stored) stored.result = result;
|
||||
|
||||
this.renderPieces(gameId, state.pieces);
|
||||
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto, state) {
|
||||
if (!this.games.has(gameId)) return;
|
||||
|
||||
// Render from the pushed state; fall back to a fetch only if it's missing.
|
||||
if (state) this.renderFromState(gameId, state);
|
||||
else await this.renderGame(gameId);
|
||||
|
||||
this.highlightMove(gameId, moveDto);
|
||||
},
|
||||
|
||||
renderPieces(gameId, pieces) {
|
||||
this.clearBoard(gameId);
|
||||
|
||||
pieces.forEach(piece => {
|
||||
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
||||
|
||||
if (!square) return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = this.pieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "chessPiece";
|
||||
img.draggable = false;
|
||||
square.appendChild(img);
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(gameId) {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`sq-${gameId}-${i}`);
|
||||
|
||||
if (!square) continue;
|
||||
|
||||
square.innerHTML = "";
|
||||
square.classList.remove("previous-start", "previous-end");
|
||||
}
|
||||
},
|
||||
|
||||
highlightMove(gameId, moveDto) {
|
||||
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
||||
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
||||
},
|
||||
|
||||
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
||||
const stored = this.games.get(gameId);
|
||||
const header = document.getElementById(`header-${gameId}`);
|
||||
|
||||
if (stored && header)
|
||||
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
||||
},
|
||||
|
||||
gameLabel(stored) {
|
||||
if (stored.isComputerVsComputer)
|
||||
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
||||
if (stored.isVsComputer) return "Vs CPU";
|
||||
return "Player vs Player";
|
||||
},
|
||||
|
||||
engineName(kind) {
|
||||
switch (kind) {
|
||||
case "CustomLearned": return "Learned";
|
||||
case "Custom": return "Custom";
|
||||
case "Stockfish": return "Stockfish";
|
||||
default: return kind || "CPU";
|
||||
}
|
||||
},
|
||||
|
||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
||||
if (stored.result)
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||
|
||||
const check = isCheck ? " • check" : "";
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
||||
},
|
||||
|
||||
resultTextFromState(state) {
|
||||
if (state.isCheckmate)
|
||||
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
||||
if (state.isStalemate)
|
||||
return "Draw — stalemate";
|
||||
if (state.isThreefoldRepetition)
|
||||
return "Draw — threefold repetition";
|
||||
return null;
|
||||
},
|
||||
|
||||
setResult(gameId, text) {
|
||||
const card = document.getElementById(`card-${gameId}`);
|
||||
|
||||
if (!card) return;
|
||||
|
||||
let banner = card.querySelector(".gameResult");
|
||||
|
||||
if (!text) {
|
||||
banner?.remove();
|
||||
card.querySelector(".copyPgnBtn")?.remove();
|
||||
card.classList.remove("over");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!banner) {
|
||||
banner = document.createElement("div");
|
||||
banner.className = "gameResult";
|
||||
card.appendChild(banner);
|
||||
}
|
||||
|
||||
banner.textContent = text;
|
||||
card.classList.add("over");
|
||||
this.addCopyPgn(gameId, card);
|
||||
},
|
||||
|
||||
addCopyPgn(gameId, card) {
|
||||
if (card.querySelector(".copyPgnBtn")) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "copyPgnBtn";
|
||||
btn.textContent = "Copy PGN";
|
||||
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
||||
card.appendChild(btn);
|
||||
|
||||
// Prefetch now (while the game is still in memory) so copy works during the
|
||||
// brief window before the finished game is cleaned up.
|
||||
fetch(`/api/chess/${gameId}/pgn`)
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(t => { if (t) this.pgns.set(gameId, t); })
|
||||
.catch(() => { });
|
||||
},
|
||||
|
||||
async copyPgn(gameId) {
|
||||
let pgn = this.pgns.get(gameId);
|
||||
|
||||
if (!pgn) {
|
||||
try {
|
||||
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (r.ok) pgn = await r.text();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!pgn) {
|
||||
alert("PGN is no longer available for this game.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(pgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
||||
}
|
||||
},
|
||||
|
||||
enterFullscreen(gameId) {
|
||||
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
||||
document.body.classList.add("fullscreen-open");
|
||||
},
|
||||
|
||||
exitFullscreen(gameId, event) {
|
||||
event?.stopPropagation();
|
||||
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
||||
document.body.classList.remove("fullscreen-open");
|
||||
},
|
||||
|
||||
pieceImageUrl(piece) {
|
||||
const color = piece.color === 0 ? "White" : "Black";
|
||||
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => Spectate.init());
|
||||
|
||||
console.log("Spectate.js loaded");
|
||||
@@ -0,0 +1,158 @@
|
||||
const Weights = {
|
||||
async init() {
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
let data;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/weights");
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not load weights.", err);
|
||||
document.getElementById("weightsStatus").textContent = "Could not load weights.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderBoardSet(data.mg, "weightsGridMg");
|
||||
this.renderBoardSet(data.eg, "weightsGridEg");
|
||||
this.renderFeatures(data.features);
|
||||
|
||||
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|
||||
|| data.features.some(f => f.value !== 0);
|
||||
|
||||
document.getElementById("weightsStatus").textContent = trained
|
||||
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
|
||||
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
|
||||
},
|
||||
|
||||
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
|
||||
renderBoardSet(pieces, containerId) {
|
||||
const overall = new Array(64).fill(0);
|
||||
for (const piece of pieces)
|
||||
for (let sq = 0; sq < 64; sq++)
|
||||
overall[sq] += piece.squares[sq];
|
||||
|
||||
const boards = [{ name: "Overall", squares: overall }, ...pieces];
|
||||
const grid = document.getElementById(containerId);
|
||||
grid.innerHTML = "";
|
||||
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
|
||||
},
|
||||
|
||||
buildBoard(board) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "weightBoard";
|
||||
|
||||
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "weightBoardHeader";
|
||||
if (board.name !== "Overall") {
|
||||
const icon = document.createElement("img");
|
||||
icon.src = `/images/Chess Images/White${board.name}.svg`;
|
||||
icon.alt = board.name;
|
||||
icon.className = "weightBoardIcon";
|
||||
header.appendChild(icon);
|
||||
}
|
||||
const title = document.createElement("span");
|
||||
title.textContent = board.name;
|
||||
header.appendChild(title);
|
||||
const range = document.createElement("span");
|
||||
range.className = "weightRange";
|
||||
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
|
||||
header.appendChild(range);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const heat = document.createElement("div");
|
||||
heat.className = "miniHeat";
|
||||
|
||||
for (let row = 0; row < 8; row++) {
|
||||
const rankLabel = document.createElement("div");
|
||||
rankLabel.className = "heatLabel";
|
||||
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
|
||||
heat.appendChild(rankLabel);
|
||||
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const rank = 7 - row; // rank index, 0 = rank 1
|
||||
const sq = rank * 8 + col; // white-relative square (A1 = 0)
|
||||
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
|
||||
}
|
||||
}
|
||||
|
||||
heat.appendChild(this.cornerSpacer());
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const fileLabel = document.createElement("div");
|
||||
fileLabel.className = "heatLabel";
|
||||
fileLabel.textContent = String.fromCharCode(97 + col);
|
||||
heat.appendChild(fileLabel);
|
||||
}
|
||||
|
||||
wrapper.appendChild(heat);
|
||||
return wrapper;
|
||||
},
|
||||
|
||||
buildSquare(value, sq, maxAbs) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "heatSquare";
|
||||
|
||||
if (value !== 0 && maxAbs > 0) {
|
||||
const ratio = Math.abs(value) / maxAbs;
|
||||
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
|
||||
cell.style.backgroundColor = value > 0
|
||||
? `rgba(74, 134, 232, ${alpha})` // high -> blue
|
||||
: `rgba(232, 74, 74, ${alpha})`; // low -> red
|
||||
cell.textContent = value;
|
||||
}
|
||||
|
||||
const file = String.fromCharCode(97 + (sq & 7));
|
||||
const rank = (sq >> 3) + 1;
|
||||
cell.title = `${file}${rank}: ${value}`;
|
||||
return cell;
|
||||
},
|
||||
|
||||
cornerSpacer() {
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "heatLabel";
|
||||
return spacer;
|
||||
},
|
||||
|
||||
// features: [{ name, value }]
|
||||
renderFeatures(features) {
|
||||
const panel = document.getElementById("featureWeights");
|
||||
panel.innerHTML = "";
|
||||
|
||||
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
|
||||
|
||||
features.forEach(feature => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "featureRow";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "featureLabel";
|
||||
label.textContent = feature.name;
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "featureTrack";
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "featureBar";
|
||||
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
|
||||
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
||||
bar.style.backgroundColor = feature.value >= 0
|
||||
? "rgba(74, 134, 232, 0.85)"
|
||||
: "rgba(232, 74, 74, 0.85)";
|
||||
track.appendChild(bar);
|
||||
|
||||
const value = document.createElement("span");
|
||||
value.className = "featureValue";
|
||||
value.textContent = feature.value;
|
||||
|
||||
row.append(label, track, value);
|
||||
panel.appendChild(row);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => Weights.init());
|
||||
|
||||
console.log("Weights.js loaded");
|
||||
@@ -1,5 +1,65 @@
|
||||
let lastPgn = null;
|
||||
|
||||
async function showCopyPgn(gameId) {
|
||||
try {
|
||||
lastPgn = await ChessAPI.getPgn(gameId);
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "";
|
||||
} catch (err) {
|
||||
console.warn("Could not load PGN.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPgn() {
|
||||
if (!lastPgn) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastPgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCopyPgn() {
|
||||
lastPgn = null;
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "none";
|
||||
}
|
||||
|
||||
window.copyPgn = copyPgn;
|
||||
window.showCopyPgn = showCopyPgn;
|
||||
|
||||
// Mobile slide-up menu (move list + secondary actions).
|
||||
function toggleMenu() {
|
||||
document.body.classList.toggle("menu-open");
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
document.body.classList.remove("menu-open");
|
||||
}
|
||||
|
||||
window.toggleMenu = toggleMenu;
|
||||
window.closeMenu = closeMenu;
|
||||
|
||||
async function forfeitCurrentGame() {
|
||||
const gameId = ChessUtils.getCookie("chessGameId");
|
||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||
|
||||
if (!gameId || !playerId) return;
|
||||
|
||||
try {
|
||||
await ChessAPI.forfeit(gameId, playerId);
|
||||
} catch (err) {
|
||||
console.warn("Could not forfeit previous game.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const gameData = await ChessAPI.joinGame();
|
||||
@@ -16,7 +76,7 @@ async function startNewGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start new game:", error);
|
||||
@@ -24,11 +84,15 @@ async function startNewGame() {
|
||||
}
|
||||
|
||||
async function startCPUGame() {
|
||||
closeMenu();
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const difficulty = await ChessModals.promptDifficulty();
|
||||
const gameData = await ChessAPI.createCPUGame(difficulty);
|
||||
const color = await ChessModals.promptColor();
|
||||
const gameData = await ChessAPI.createCPUGame(difficulty, color);
|
||||
|
||||
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||
GameState.clearPreviousMove();
|
||||
@@ -42,7 +106,7 @@ async function startCPUGame() {
|
||||
await ChessSignalR.setupConnection();
|
||||
|
||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to start CPU game:", error);
|
||||
@@ -62,7 +126,7 @@ async function resumeSavedGame() {
|
||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||
|
||||
await ChessSignalR.setupConnection();
|
||||
ChessBoard.renderPieces(gameState.pieces);
|
||||
ChessBoard.renderState(gameState);
|
||||
|
||||
} catch (err) {
|
||||
console.warn("Saved game not found or expired.", err);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
const hooks = {
|
||||
save: s => HoldemState.save(s),
|
||||
rerender: () => Render.renderAll(),
|
||||
};
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
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", () => {
|
||||
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
|
||||
HoldemState.startNextHand(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("resetTableBtn").addEventListener("click", () => {
|
||||
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
|
||||
HoldemState.clear();
|
||||
Object.assign(state, HoldemState.createDefault());
|
||||
Render.mount(state, hooks);
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||
PotWizard.open(state, () => {
|
||||
HoldemState.startNextHand(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
||||
});
|
||||
});
|
||||
@@ -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,196 @@
|
||||
// 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 = player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
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", () => {
|
||||
this.close();
|
||||
this.onFinished();
|
||||
});
|
||||
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,363 @@
|
||||
// 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();
|
||||
},
|
||||
|
||||
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 player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
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", () => {
|
||||
this.selectedSeatIndex = 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) ----
|
||||
|
||||
renderSeatEditor() {
|
||||
const title = document.getElementById("seatEditorTitle");
|
||||
const body = document.getElementById("seatEditorBody");
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const Money = {
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Render, Money };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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: [],
|
||||
};
|
||||
},
|
||||
|
||||
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: "",
|
||||
};
|
||||
},
|
||||
|
||||
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();
|
||||
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;
|
||||
},
|
||||
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HoldemState;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
# Particle Simulator - Modular Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The particle simulator has been refactored from a single 780-line monolithic file into a clean, modular architecture with separate ES6 modules for each concern.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
particles/
|
||||
├── src/ Source modules (ES6)
|
||||
│ ├── constants.js Physics & UI constants (40 lines)
|
||||
│ ├── utils.js Utility functions (47 lines)
|
||||
│ ├── random.js PRNG implementation (18 lines)
|
||||
│ ├── colors.js Color palette configuration (16 lines)
|
||||
│ ├── canvas.js Canvas utilities (37 lines)
|
||||
│ ├── particles.js Particle state management (96 lines)
|
||||
│ ├── spatial-grid.js Spatial grid optimization (76 lines)
|
||||
│ ├── rules.js Interaction rules (71 lines)
|
||||
│ ├── physics.js Physics simulation (193 lines)
|
||||
│ ├── renderer.js Rendering logic (35 lines)
|
||||
│ ├── ui/
|
||||
│ │ ├── toolbar.js Toolbar responsiveness (34 lines)
|
||||
│ │ └── rule-editor.js Rule editor controls (72 lines)
|
||||
│ └── main.js Application entry point (213 lines)
|
||||
├── build.js Simple bundler script
|
||||
├── page.bundle.js Built output (auto-generated)
|
||||
└── README.md This file
|
||||
```
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
### Core Modules
|
||||
|
||||
- **constants.js** - All configurable physics parameters and constants
|
||||
- **utils.js** - General-purpose utility functions (formatNumber, wrapPosition, etc.)
|
||||
- **random.js** - Deterministic pseudo-random number generator (mulberry32)
|
||||
- **colors.js** - Particle color palette and labels
|
||||
|
||||
### Simulation Modules
|
||||
|
||||
- **particles.js** - Particle state using Structure of Arrays (SoA) pattern
|
||||
- **spatial-grid.js** - Spatial partitioning for O(1) neighbor queries
|
||||
- **physics.js** - Force calculations, integration, and collision resolution
|
||||
- **renderer.js** - Canvas rendering with motion trails
|
||||
- **rules.js** - Interaction matrix generation and validation
|
||||
|
||||
### UI Modules
|
||||
|
||||
- **canvas.js** - Canvas initialization and DPI scaling
|
||||
- **ui/toolbar.js** - Responsive toolbar (mobile FAB / desktop bar)
|
||||
- **ui/rule-editor.js** - Interactive rule editing controls
|
||||
- **main.js** - Application orchestration and event handling
|
||||
|
||||
## Building
|
||||
|
||||
The project uses a simple custom bundler (`build.js`) that combines all ES6 modules into a single IIFE for browser compatibility.
|
||||
|
||||
### Automatic Build on Startup (Recommended)
|
||||
|
||||
The bundle is **automatically built when the application starts**. The `ParticleBundler` service in `Program.cs` runs the Node.js build script before `app.Run()`.
|
||||
|
||||
When you start the application with `dotnet run`, you'll see:
|
||||
```
|
||||
🔨 Building particle simulator bundle...
|
||||
✓ Processing constants.js
|
||||
✓ Processing utils.js
|
||||
...
|
||||
✨ Bundle created successfully!
|
||||
```
|
||||
|
||||
### Manual Build (Optional)
|
||||
|
||||
You can also manually rebuild the bundle:
|
||||
|
||||
```bash
|
||||
cd JoshHeaps.Net/wwwroot/js/particles
|
||||
node build.js
|
||||
```
|
||||
|
||||
### Build output:
|
||||
- All modules are concatenated in dependency order
|
||||
- Import/export statements are removed
|
||||
- Code is wrapped in an IIFE (Immediately Invoked Function Expression)
|
||||
- Result is ~38 KB of clean, minification-ready code
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Edit source modules** in `src/` directory
|
||||
2. **Start the application**: `dotnet run` (bundle is built automatically)
|
||||
3. **Test in browser** - The bundle is automatically used by the page
|
||||
4. **Commit** source modules (bundle is auto-generated, but should be committed too)
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
### 🎯 Single Responsibility
|
||||
Each module has one clear, focused purpose. No file exceeds 200 lines.
|
||||
|
||||
### 🧪 Testable
|
||||
Individual modules can be tested in isolation.
|
||||
|
||||
### 📖 Readable
|
||||
Easy to navigate and understand. New developers can quickly find what they need.
|
||||
|
||||
### ♻️ Reusable
|
||||
Modules like `spatial-grid.js` or `random.js` can be reused in other projects.
|
||||
|
||||
### 🔍 Maintainable
|
||||
Bug fixes and feature additions are localized to specific modules.
|
||||
|
||||
### 📦 Dependency Clarity
|
||||
Import statements clearly show what each module depends on.
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
### Structure of Arrays (SoA)
|
||||
The `particles.js` module uses separate typed arrays for each property (position, velocity, color) instead of an array of particle objects. This improves cache locality and performance.
|
||||
|
||||
### Encapsulation
|
||||
Internal state is hidden. Modules expose only necessary functions through exports.
|
||||
|
||||
### Separation of Concerns
|
||||
- Physics logic is completely separate from rendering
|
||||
- UI controls are separate from simulation logic
|
||||
- State management is centralized in `particles.js`
|
||||
|
||||
### Dependency Injection
|
||||
The `main.js` module wires everything together, passing dependencies explicitly.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This modular architecture maintains 100% compatibility with the original implementation:
|
||||
- Same visual output
|
||||
- Same physics behavior
|
||||
- Same UI/UX
|
||||
- Same performance characteristics
|
||||
|
||||
The ONLY difference is the code organization, which is dramatically improved.
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple ES6 Module Bundler
|
||||
* Bundles ES6 modules into a single IIFE for browser compatibility
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const srcDir = path.join(__dirname, 'src');
|
||||
const outputFile = path.join(__dirname, 'page.bundle.js');
|
||||
|
||||
// Module dependency order (topological sort)
|
||||
const moduleOrder = [
|
||||
'constants.js',
|
||||
'utils.js',
|
||||
'random.js',
|
||||
'colors.js',
|
||||
'canvas.js',
|
||||
'particles.js',
|
||||
'spatial-grid.js',
|
||||
'rules.js',
|
||||
'physics.js',
|
||||
'renderer.js',
|
||||
'ui/toolbar.js',
|
||||
'ui/rule-editor.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
/**
|
||||
* Read and process a module file
|
||||
* @param {string} modulePath - Path to module file
|
||||
* @returns {string} Processed module content
|
||||
*/
|
||||
function processModule(modulePath) {
|
||||
const fullPath = path.join(srcDir, modulePath);
|
||||
let content = fs.readFileSync(fullPath, 'utf8');
|
||||
|
||||
// Remove export statements (we'll use direct assignment in IIFE)
|
||||
content = content.replace(/export\s+(const|let|var|function|class)\s+/g, '$1 ');
|
||||
content = content.replace(/export\s+\{[^}]+\};?/g, '');
|
||||
content = content.replace(/export\s+default\s+/g, '');
|
||||
|
||||
// Remove import statements (modules are already in order)
|
||||
content = content.replace(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||
content = content.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||
content = content.replace(/import\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle all modules into a single file
|
||||
*/
|
||||
function bundle() {
|
||||
console.log('🔨 Building particle simulator bundle...');
|
||||
|
||||
let bundledContent = '(function () {\n "use strict";\n\n';
|
||||
|
||||
// Process each module in dependency order
|
||||
for (const modulePath of moduleOrder) {
|
||||
console.log(` ✓ Processing ${modulePath}`);
|
||||
const moduleContent = processModule(modulePath);
|
||||
bundledContent += ` // ========================================\n`;
|
||||
bundledContent += ` // MODULE: ${modulePath}\n`;
|
||||
bundledContent += ` // ========================================\n\n`;
|
||||
|
||||
// Indent module content
|
||||
const indentedContent = moduleContent
|
||||
.split('\n')
|
||||
.map(line => line.length > 0 ? ' ' + line : line)
|
||||
.join('\n');
|
||||
|
||||
bundledContent += indentedContent + '\n\n';
|
||||
}
|
||||
|
||||
bundledContent += '})();\n';
|
||||
|
||||
// Write bundle to output file
|
||||
fs.writeFileSync(outputFile, bundledContent, 'utf8');
|
||||
|
||||
const stats = fs.statSync(outputFile);
|
||||
const sizeKB = (stats.size / 1024).toFixed(2);
|
||||
|
||||
console.log(`✨ Bundle created successfully!`);
|
||||
console.log(` Output: ${outputFile}`);
|
||||
console.log(` Size: ${sizeKB} KB`);
|
||||
}
|
||||
|
||||
// Run bundler
|
||||
try {
|
||||
bundle();
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Canvas Utilities
|
||||
* Canvas setup and resizing functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prepare canvas context with proper DPI scaling
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {CanvasRenderingContext2D}
|
||||
*/
|
||||
export function prepareCanvasContext(canvas) {
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
resizeCanvasToDisplaySize(canvas, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas to match display size with device pixel ratio scaling
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function resizeCanvasToDisplaySize(canvas, context) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
// Scale context so we can draw in CSS pixels
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Color Configuration
|
||||
* Particle color palette and naming
|
||||
*/
|
||||
|
||||
export const COLOR_NAMES = ["R", "G", "B", "Y", "C", "M"];
|
||||
|
||||
export const PALETTE = [
|
||||
"#ff0000", // Red
|
||||
"#00ff00", // Green
|
||||
"#0066ff", // Blue
|
||||
"#ffff00", // Yellow
|
||||
"#00ffff", // Cyan
|
||||
"#ff00ff" // Magenta
|
||||
];
|
||||
|
||||
export const COLOR_LABELS = ["Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"];
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Physics and UI Constants
|
||||
* All configurable parameters for the particle simulation
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// PHYSICS CONSTANTS
|
||||
// ========================================
|
||||
|
||||
// Force calculation parameters
|
||||
export const FORCE_SOFTEN = 25; // Softening factor (px^2) to prevent division by zero
|
||||
export const MAX_ACCELERATION = 1.5; // Maximum acceleration magnitude (px/s^2)
|
||||
export const VELOCITY_DAMPING = 0.985; // Velocity damping coefficient per step
|
||||
export const INTERACTION_RANGE = 240; // Max distance for particle interactions (px)
|
||||
export const INTERACTION_RANGE_SQUARED = INTERACTION_RANGE * INTERACTION_RANGE;
|
||||
|
||||
// Rendering parameters
|
||||
export const PARTICLE_RADIUS = 2; // Visual radius of each particle (px)
|
||||
export const TRAIL_ALPHA = 0.1; // Fade alpha for motion trails (lower = longer trails)
|
||||
|
||||
// Interaction rule constraints
|
||||
export const RULE_MIN = -1; // Minimum interaction strength
|
||||
export const RULE_MAX = 1; // Maximum interaction strength
|
||||
export const RULE_SELF_LIMIT = 0.6; // Self-interaction strength limit
|
||||
export const RULE_STABILITY_THRESHOLD = 3; // Max sum of absolute values per row
|
||||
|
||||
// Collision parameters (simple barrier)
|
||||
export const BARRIER_RADIUS = PARTICLE_RADIUS * 2.0; // Collision radius (4px - particles stay separated)
|
||||
export const BARRIER_DIAMETER = BARRIER_RADIUS * 2; // Full barrier diameter (8px)
|
||||
export const BARRIER_RESTITUTION = 0.8; // Bounciness coefficient (0.8 = bouncy)
|
||||
export const BARRIER_FRICTION = 0.001; // Very low friction (smooth motion)
|
||||
|
||||
// Collision solver
|
||||
export const COLLISION_ITERATIONS = 1; // Solver iterations per substep
|
||||
|
||||
// Simulation timing
|
||||
export const FIXED_TIMESTEP = 1 / 60; // 60 FPS physics update
|
||||
export const MAX_FRAME_DELTA = 0.1; // Prevent spiral of death (seconds)
|
||||
|
||||
// Spatial grid optimization
|
||||
export const CELL_SIZE = Math.max(INTERACTION_RANGE, BARRIER_DIAMETER * 2);
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* Particle Simulator - Main Application Entry Point
|
||||
* Initializes and orchestrates the particle simulation system
|
||||
*/
|
||||
|
||||
import {
|
||||
FIXED_TIMESTEP,
|
||||
MAX_FRAME_DELTA,
|
||||
COLLISION_ITERATIONS,
|
||||
RULE_MIN,
|
||||
RULE_MAX
|
||||
} from './constants.js';
|
||||
import { getElementById, formatNumber } from './utils.js';
|
||||
import { createRandomGenerator } from './random.js';
|
||||
import { prepareCanvasContext, resizeCanvasToDisplaySize } from './canvas.js';
|
||||
import { createParticles } from './particles.js';
|
||||
import { rebuildSpatialGrid } from './spatial-grid.js';
|
||||
import { simulatePhysicsStep, resolveCollisions } from './physics.js';
|
||||
import { renderParticles } from './renderer.js';
|
||||
import { buildInteractionRules } from './rules.js';
|
||||
import { setupToolbarResponsiveness } from './ui/toolbar.js';
|
||||
import { setupRuleEditor } from './ui/rule-editor.js';
|
||||
|
||||
/**
|
||||
* Main application initialization
|
||||
* Initializes simulation, UI, and event handlers
|
||||
*/
|
||||
function initializeApplication() {
|
||||
const bootConfig = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 };
|
||||
|
||||
// Canvas setup
|
||||
const canvas = getElementById("sim");
|
||||
const context = prepareCanvasContext(canvas);
|
||||
|
||||
// Input controls
|
||||
const seedInput = getElementById("seed");
|
||||
const countInput = getElementById("count");
|
||||
const speedRange = getElementById("speed");
|
||||
const speedOutput = getElementById("speedOut");
|
||||
|
||||
// Action buttons
|
||||
const restartButton = getElementById("restart");
|
||||
const permalinkButton = getElementById("permalink");
|
||||
const randomSeedButton = getElementById("randomSeed");
|
||||
|
||||
// Rule editor elements
|
||||
const activeColorSelect = getElementById("ruleActiveColor");
|
||||
const sliderIds = ["R", "G", "B", "Y", "C", "M"];
|
||||
const sliders = sliderIds.map(id => ({
|
||||
input: getElementById("rule_" + id),
|
||||
output: getElementById("rule_" + id + "_out")
|
||||
}));
|
||||
const copyRulesButton = getElementById("copyRules");
|
||||
const toggleImportButton = getElementById("toggleImport");
|
||||
const importArea = getElementById("importArea");
|
||||
const rulesJsonTextarea = getElementById("rulesJson");
|
||||
const applyRulesButton = getElementById("applyRules");
|
||||
const cancelImportButton = getElementById("cancelImport");
|
||||
|
||||
// Toolbar elements
|
||||
const toolbarElement = document.querySelector(".toolbar");
|
||||
const toggleToolbarButton = getElementById("toggleToolbar");
|
||||
|
||||
// Initialize input values from boot config
|
||||
seedInput.value = String(bootConfig.seed >>> 0);
|
||||
countInput.value = String(bootConfig.n);
|
||||
speedRange.value = String(bootConfig.speed);
|
||||
speedOutput.textContent = speedRange.value;
|
||||
|
||||
// Simulation state
|
||||
let randomGenerator;
|
||||
let interactionRules;
|
||||
let worldWidth = canvas.clientWidth;
|
||||
let worldHeight = canvas.clientHeight;
|
||||
|
||||
// Animation timing (fixed timestep with accumulator)
|
||||
let timeAccumulator = 0;
|
||||
let lastFrameTime = performance.now();
|
||||
let isRunning = true;
|
||||
|
||||
/**
|
||||
* Initialize/restart simulation with new parameters
|
||||
*/
|
||||
function initializeSimulation(seed, particleCount) {
|
||||
randomGenerator = createRandomGenerator(seed >>> 0);
|
||||
interactionRules = buildInteractionRules(randomGenerator);
|
||||
worldWidth = canvas.clientWidth;
|
||||
worldHeight = canvas.clientHeight;
|
||||
createParticles(randomGenerator, particleCount, worldWidth, worldHeight);
|
||||
syncRuleUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync rule editor UI with current interaction rules
|
||||
*/
|
||||
function syncRuleUI() {
|
||||
if (!interactionRules) return;
|
||||
|
||||
const sourceColor = parseInt(activeColorSelect.value, 10) || 0;
|
||||
for (let targetColor = 0; targetColor < sliders.length; targetColor++) {
|
||||
const value = Math.max(RULE_MIN, Math.min(RULE_MAX,
|
||||
interactionRules[sourceColor][targetColor] ?? 0));
|
||||
sliders[targetColor].input.value = String(value);
|
||||
sliders[targetColor].output.textContent = formatNumber(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main animation frame update loop
|
||||
*/
|
||||
function animationFrame(currentTime) {
|
||||
if (!isRunning) return;
|
||||
|
||||
// Update canvas size
|
||||
resizeCanvasToDisplaySize(canvas, context);
|
||||
worldWidth = canvas.clientWidth;
|
||||
worldHeight = canvas.clientHeight;
|
||||
|
||||
const speedMultiplier = +speedRange.value || 1;
|
||||
|
||||
// Fixed timestep accumulator (prevents spiral of death)
|
||||
timeAccumulator += Math.min(MAX_FRAME_DELTA, (currentTime - lastFrameTime) / 1000);
|
||||
lastFrameTime = currentTime;
|
||||
|
||||
// Run physics updates at fixed timestep
|
||||
while (timeAccumulator >= FIXED_TIMESTEP) {
|
||||
rebuildSpatialGrid(worldWidth, worldHeight);
|
||||
simulatePhysicsStep(FIXED_TIMESTEP, speedMultiplier, worldWidth, worldHeight, interactionRules);
|
||||
|
||||
// Rebuild grid after positions changed
|
||||
rebuildSpatialGrid(worldWidth, worldHeight);
|
||||
|
||||
// Resolve collisions
|
||||
for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration++) {
|
||||
resolveCollisions(worldWidth, worldHeight);
|
||||
}
|
||||
|
||||
timeAccumulator -= FIXED_TIMESTEP;
|
||||
}
|
||||
|
||||
renderParticles(context, worldWidth, worldHeight);
|
||||
requestAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// UI EVENT BINDINGS
|
||||
// ========================================
|
||||
|
||||
// Speed slider updates output display
|
||||
speedRange.addEventListener("input", () => {
|
||||
speedOutput.textContent = speedRange.value;
|
||||
});
|
||||
|
||||
// Setup toolbar responsiveness
|
||||
setupToolbarResponsiveness(toolbarElement, toggleToolbarButton);
|
||||
|
||||
// Setup rule editor
|
||||
const rulesReference = {
|
||||
get rules() { return interactionRules; },
|
||||
set rules(v) { interactionRules = v; }
|
||||
};
|
||||
setupRuleEditor({
|
||||
activeColorSelect,
|
||||
sliders,
|
||||
copyButton: copyRulesButton,
|
||||
toggleImportButton,
|
||||
importArea,
|
||||
rulesJsonTextarea,
|
||||
applyButton: applyRulesButton,
|
||||
cancelButton: cancelImportButton
|
||||
}, syncRuleUI, rulesReference);
|
||||
|
||||
// Restart simulation button
|
||||
restartButton.addEventListener("click", () => {
|
||||
const seed = (seedInput.value === "") ? (bootConfig.seed >>> 0) :
|
||||
(parseInt(seedInput.value, 10) >>> 0);
|
||||
const count = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || bootConfig.n));
|
||||
initializeSimulation(seed, count);
|
||||
});
|
||||
|
||||
// Permalink button - copy shareable URL
|
||||
permalinkButton.addEventListener("click", async () => {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("seed", String(seedInput.value || bootConfig.seed));
|
||||
url.searchParams.set("n", String(countInput.value || bootConfig.n));
|
||||
url.searchParams.set("speed", String(speedRange.value || bootConfig.speed));
|
||||
history.replaceState({}, "", url);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
permalinkButton.textContent = "Copied!";
|
||||
setTimeout(() => permalinkButton.textContent = "Permalink", 800);
|
||||
} catch (error) {
|
||||
// Clipboard access denied - ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Random seed button
|
||||
randomSeedButton.addEventListener("click", () => {
|
||||
const buffer = new Uint32Array(1);
|
||||
(window.crypto || window.msCrypto).getRandomValues(buffer);
|
||||
seedInput.value = String(buffer[0] >>> 0);
|
||||
});
|
||||
|
||||
// Pause simulation when tab is hidden (battery saving)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
isRunning = document.visibilityState !== "hidden";
|
||||
if (isRunning) {
|
||||
lastFrameTime = performance.now();
|
||||
requestAnimationFrame(animationFrame);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resizes
|
||||
window.addEventListener("resize", () => {
|
||||
resizeCanvasToDisplaySize(canvas, context);
|
||||
});
|
||||
|
||||
// Start simulation
|
||||
initializeSimulation(bootConfig.seed >>> 0, bootConfig.n);
|
||||
requestAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// APPLICATION BOOTSTRAP
|
||||
// ========================================
|
||||
|
||||
// Start application when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initializeApplication, { once: true });
|
||||
} else {
|
||||
initializeApplication();
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Particle State Management
|
||||
* Structure of Arrays (SoA) for efficient particle storage
|
||||
*/
|
||||
|
||||
import { COLOR_NAMES } from './colors.js';
|
||||
|
||||
// ========================================
|
||||
// PARTICLE STATE (Structure of Arrays)
|
||||
// ========================================
|
||||
|
||||
let particlePositionX; // Float32Array - X positions
|
||||
let particlePositionY; // Float32Array - Y positions
|
||||
let particleVelocityX; // Float32Array - X velocities
|
||||
let particleVelocityY; // Float32Array - Y velocities
|
||||
let particleColor; // Uint8Array - Color indices
|
||||
let particleCount = 0;
|
||||
|
||||
/**
|
||||
* Initialize particle system with random positions and velocities
|
||||
* @param {function} random - Random number generator
|
||||
* @param {number} count - Number of particles to create
|
||||
* @param {number} width - Simulation width
|
||||
* @param {number} height - Simulation height
|
||||
*/
|
||||
export function createParticles(random, count, width, height) {
|
||||
particlePositionX = new Float32Array(count);
|
||||
particlePositionY = new Float32Array(count);
|
||||
particleVelocityX = new Float32Array(count);
|
||||
particleVelocityY = new Float32Array(count);
|
||||
particleColor = new Uint8Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
particlePositionX[i] = random() * width;
|
||||
particlePositionY[i] = random() * height;
|
||||
particleVelocityX[i] = (random() - 0.5) * 0.1;
|
||||
particleVelocityY[i] = (random() - 0.5) * 0.1;
|
||||
// Round-robin color assignment for even distribution
|
||||
particleColor[i] = i % COLOR_NAMES.length;
|
||||
}
|
||||
particleCount = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current particle count
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getParticleCount() {
|
||||
return particleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get particle position arrays (read-only access)
|
||||
* @returns {{x: Float32Array, y: Float32Array}}
|
||||
*/
|
||||
export function getParticlePositions() {
|
||||
return {
|
||||
x: particlePositionX,
|
||||
y: particlePositionY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get particle velocity arrays (read-only access)
|
||||
* @returns {{x: Float32Array, y: Float32Array}}
|
||||
*/
|
||||
export function getParticleVelocities() {
|
||||
return {
|
||||
x: particleVelocityX,
|
||||
y: particleVelocityY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get particle color array (read-only access)
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function getParticleColors() {
|
||||
return particleColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a particle's position
|
||||
* @param {number} index - Particle index
|
||||
* @param {number} x - New X position
|
||||
* @param {number} y - New Y position
|
||||
*/
|
||||
export function setParticlePosition(index, x, y) {
|
||||
particlePositionX[index] = x;
|
||||
particlePositionY[index] = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a particle's velocity
|
||||
* @param {number} index - Particle index
|
||||
* @param {number} vx - New X velocity
|
||||
* @param {number} vy - New Y velocity
|
||||
*/
|
||||
export function setParticleVelocity(index, vx, vy) {
|
||||
particleVelocityX[index] = vx;
|
||||
particleVelocityY[index] = vy;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Physics Simulation
|
||||
* Force calculations, integration, and collision resolution
|
||||
*/
|
||||
|
||||
import {
|
||||
FORCE_SOFTEN,
|
||||
MAX_ACCELERATION,
|
||||
VELOCITY_DAMPING,
|
||||
INTERACTION_RANGE,
|
||||
INTERACTION_RANGE_SQUARED,
|
||||
BARRIER_RADIUS,
|
||||
BARRIER_DIAMETER,
|
||||
BARRIER_RESTITUTION,
|
||||
BARRIER_FRICTION
|
||||
} from './constants.js';
|
||||
import { wrapDistance, wrapPosition } from './utils.js';
|
||||
import {
|
||||
getParticleCount,
|
||||
getParticlePositions,
|
||||
getParticleVelocities,
|
||||
getParticleColors,
|
||||
setParticlePosition,
|
||||
setParticleVelocity
|
||||
} from './particles.js';
|
||||
import { getGridCell, forEachNeighborParticle } from './spatial-grid.js';
|
||||
|
||||
/**
|
||||
* Update particle physics for one timestep
|
||||
* Calculates forces based on interaction rules, integrates motion, and wraps positions
|
||||
* @param {number} deltaTime - Time step duration (seconds)
|
||||
* @param {number} speedMultiplier - Speed multiplier from user input
|
||||
* @param {number} worldWidth - Width of simulation space
|
||||
* @param {number} worldHeight - Height of simulation space
|
||||
* @param {number[][]} interactionRules - 6x6 matrix of color interaction strengths
|
||||
*/
|
||||
export function simulatePhysicsStep(deltaTime, speedMultiplier, worldWidth, worldHeight, interactionRules) {
|
||||
const halfWidth = worldWidth * 0.5;
|
||||
const halfHeight = worldHeight * 0.5;
|
||||
|
||||
const particleCount = getParticleCount();
|
||||
const positions = getParticlePositions();
|
||||
const velocities = getParticleVelocities();
|
||||
const colors = getParticleColors();
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
let accelerationX = 0;
|
||||
let accelerationY = 0;
|
||||
|
||||
// Find grid cell for this particle
|
||||
const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]);
|
||||
|
||||
// Compute forces from nearby particles
|
||||
forEachNeighborParticle(cellX, cellY, (j) => {
|
||||
if (j === i) return; // Skip self
|
||||
|
||||
// Calculate toroidal (wrapping) distance
|
||||
let deltaX = positions.x[j] - positions.x[i];
|
||||
let deltaY = positions.y[j] - positions.y[i];
|
||||
|
||||
deltaX = wrapDistance(deltaX, halfWidth, worldWidth);
|
||||
deltaY = wrapDistance(deltaY, halfHeight, worldHeight);
|
||||
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
|
||||
// Skip if beyond interaction range
|
||||
if (INTERACTION_RANGE && distanceSquared > INTERACTION_RANGE_SQUARED) return;
|
||||
|
||||
// Apply interaction force: F = k / (r^2 + soften)
|
||||
const ruleStrength = interactionRules[colors[i]][colors[j]];
|
||||
const forceFactor = 1 / (distanceSquared + FORCE_SOFTEN);
|
||||
accelerationX += ruleStrength * deltaX * forceFactor;
|
||||
accelerationY += ruleStrength * deltaY * forceFactor;
|
||||
});
|
||||
|
||||
// Clamp acceleration magnitude
|
||||
const accelerationMagnitudeSquared = accelerationX * accelerationX + accelerationY * accelerationY;
|
||||
if (accelerationMagnitudeSquared > MAX_ACCELERATION * MAX_ACCELERATION) {
|
||||
const scale = MAX_ACCELERATION / Math.sqrt(accelerationMagnitudeSquared);
|
||||
accelerationX *= scale;
|
||||
accelerationY *= scale;
|
||||
}
|
||||
|
||||
// Integrate velocity (with damping)
|
||||
const newVx = (velocities.x[i] + accelerationX * deltaTime * speedMultiplier) * VELOCITY_DAMPING;
|
||||
const newVy = (velocities.y[i] + accelerationY * deltaTime * speedMultiplier) * VELOCITY_DAMPING;
|
||||
setParticleVelocity(i, newVx, newVy);
|
||||
|
||||
// Integrate position
|
||||
const newPx = positions.x[i] + newVx * deltaTime * speedMultiplier;
|
||||
const newPy = positions.y[i] + newVy * deltaTime * speedMultiplier;
|
||||
|
||||
// Wrap positions (toroidal world)
|
||||
setParticlePosition(
|
||||
i,
|
||||
wrapPosition(newPx, worldWidth),
|
||||
wrapPosition(newPy, worldHeight)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve particle-particle collisions using simple barrier physics
|
||||
* Particles have a barrier radius that prevents overlap with elastic bouncing
|
||||
* @param {number} worldWidth - Width of simulation space
|
||||
* @param {number} worldHeight - Height of simulation space
|
||||
*/
|
||||
export function resolveCollisions(worldWidth, worldHeight) {
|
||||
const halfWidth = worldWidth * 0.5;
|
||||
const halfHeight = worldHeight * 0.5;
|
||||
const barrierDistanceSquared = BARRIER_DIAMETER * BARRIER_DIAMETER;
|
||||
|
||||
const particleCount = getParticleCount();
|
||||
const positions = getParticlePositions();
|
||||
const velocities = getParticleVelocities();
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]);
|
||||
|
||||
forEachNeighborParticle(cellX, cellY, (j) => {
|
||||
// Only process each pair once (i < j)
|
||||
if (j <= i) return;
|
||||
|
||||
// Calculate toroidal distance
|
||||
let deltaX = positions.x[j] - positions.x[i];
|
||||
let deltaY = positions.y[j] - positions.y[i];
|
||||
|
||||
deltaX = wrapDistance(deltaX, halfWidth, worldWidth);
|
||||
deltaY = wrapDistance(deltaY, halfHeight, worldHeight);
|
||||
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
|
||||
// Check if particles are colliding (within barrier distance)
|
||||
if (distanceSquared >= barrierDistanceSquared || distanceSquared === 0) return;
|
||||
|
||||
const distance = Math.sqrt(distanceSquared);
|
||||
const normalX = deltaX / distance;
|
||||
const normalY = deltaY / distance;
|
||||
|
||||
// Position correction: push particles apart to barrier distance
|
||||
const overlap = BARRIER_DIAMETER - distance;
|
||||
const correction = overlap * 0.5;
|
||||
const newPx_i = positions.x[i] - normalX * correction;
|
||||
const newPy_i = positions.y[i] - normalY * correction;
|
||||
const newPx_j = positions.x[j] + normalX * correction;
|
||||
const newPy_j = positions.y[j] + normalY * correction;
|
||||
|
||||
setParticlePosition(i, wrapPosition(newPx_i, worldWidth), wrapPosition(newPy_i, worldHeight));
|
||||
setParticlePosition(j, wrapPosition(newPx_j, worldWidth), wrapPosition(newPy_j, worldHeight));
|
||||
|
||||
// Elastic collision response with restitution
|
||||
const relativeVelocityX = velocities.x[j] - velocities.x[i];
|
||||
const relativeVelocityY = velocities.y[j] - velocities.y[i];
|
||||
const normalVelocity = relativeVelocityX * normalX + relativeVelocityY * normalY;
|
||||
|
||||
// Only resolve if particles are approaching
|
||||
if (normalVelocity < 0) {
|
||||
const impulse = (1 + BARRIER_RESTITUTION) * normalVelocity * 0.5;
|
||||
const impulseX = impulse * normalX;
|
||||
const impulseY = impulse * normalY;
|
||||
|
||||
setParticleVelocity(
|
||||
i,
|
||||
velocities.x[i] + impulseX,
|
||||
velocities.y[i] + impulseY
|
||||
);
|
||||
setParticleVelocity(
|
||||
j,
|
||||
velocities.x[j] - impulseX,
|
||||
velocities.y[j] - impulseY
|
||||
);
|
||||
}
|
||||
|
||||
// Apply minimal friction to tangential velocity
|
||||
const tangentVelocityX = relativeVelocityX - normalVelocity * normalX;
|
||||
const tangentVelocityY = relativeVelocityY - normalVelocity * normalY;
|
||||
const tangentMagnitude = Math.hypot(tangentVelocityX, tangentVelocityY);
|
||||
|
||||
if (tangentMagnitude > 1e-8) {
|
||||
const tangentX = tangentVelocityX / tangentMagnitude;
|
||||
const tangentY = tangentVelocityY / tangentMagnitude;
|
||||
const frictionImpulse = BARRIER_FRICTION * tangentMagnitude * 0.5;
|
||||
|
||||
setParticleVelocity(
|
||||
i,
|
||||
velocities.x[i] + tangentX * frictionImpulse,
|
||||
velocities.y[i] + tangentY * frictionImpulse
|
||||
);
|
||||
setParticleVelocity(
|
||||
j,
|
||||
velocities.x[j] - tangentX * frictionImpulse,
|
||||
velocities.y[j] - tangentY * frictionImpulse
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user