Author SHA1 Message Date
Josh-Heaps a53b0c2e43 Bug fixes 2026-06-13 22:01:12 -06:00
Josh-Heaps 3bfcbaa190 change deploy 2026-06-13 21:33:53 -06:00
Josh-Heaps e5e0c9cc48 revert everything, these changes were a mistake 2026-06-13 20:30:26 -06:00
Josh-Heaps b99525ae51 fix deploy 2026-06-13 20:13:54 -06:00
Josh-Heaps 9da7b1986b bug fixes 2026-06-13 20:06:56 -06:00
Josh-Heaps 7604798e68 fix deploy 2026-06-13 19:43:40 -06:00
Josh-Heaps 1680d13b05 bug fix 2026-06-13 18:47:31 -06:00
Josh-Heaps a1e2ad9c2d Bug fixes 2026-06-13 18:44:14 -06:00
Josh-Heaps aeef320cb4 auto train 2026-06-13 17:30:09 -06:00
Josh-Heaps d71e485611 New learning strategy 2026-06-13 17:11:07 -06:00
Josh-Heaps acbee436bb Change data location 2026-06-12 19:53:07 -06:00
Josh-Heaps f09ca60315 foo bar 2026-06-12 18:02:09 -06:00
Josh-Heaps 743c395e11 Choose color against cpu 2026-06-12 10:51:26 -06:00
Josh-HeapsandClaude Opus 4.8 6402ef268c Rework the chess page into a chess.com-style layout
Board with player bars and captured trays on the left, a game panel with
move list, status, and action buttons on the right. The page is locked to
the viewport (no scrolling); on phones the panel collapses into a slide-up
menu reachable from a slim status bar. Cache-busts the chess scripts too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:35 -06:00
Josh-HeapsandClaude Opus 4.8 c92c46e6d4 Render the board from pushed state
Clients now render from the state delivered with each move/update and
drop stale or echoed updates via a ply-version guard, instead of issuing
a full game-state fetch per event. Adds captured-piece trays, a material
advantage badge, a move list, a status line, and the mobile menu toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:22 -06:00
Josh-HeapsandClaude Opus 4.8 e8a3bfe432 Push authoritative game state on every move
The move endpoint now returns the full resulting board state, and the
controller and CPU orchestrator broadcast it (plus captured pieces and a
ply version) over SignalR, so clients render from one payload instead of
re-fetching. Removes the client-driven hub relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:11 -06:00
Josh Heaps 8b78fd589c Merge pull request #19 from JoshHeaps/feature/customChessEngine
Add killer-move ordering and a search benchmark
2026-06-09 19:03:21 -06:00
Josh-HeapsandClaude Opus 4.8 be07f7504e Add killer-move ordering and a search benchmark
Killer moves supply the quiet-move ordering the search lacked: ~1.45x faster at depth 8 (up to 1.88x in quiet positions) with identical play. Adds bench.ps1 and test/bench_main.cpp to time the search, with a BENCH_DISABLE_KILLERS compile toggle so the script can measure before/after.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:01:57 -06:00
Josh Heaps 7bfbae7301 Merge pull request #18 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 19:15:41 -06:00
Josh-HeapsandClaude Opus 4.8 b651683e63 Add PGN export and a Copy PGN button for finished games
- Record moves in standard algebraic notation (GameState.SanHistory), built at
  move time in ChessService (captures, disambiguation, castling, promotion,
  en passant, and the check/mate suffix)
- Add PgnExporter.ToPgn (seven-tag header + movetext + result) and a
  GET /api/chess/{gameId}/pgn endpoint
- Show a Copy PGN button when a game ends on both the play and watch pages,
  prefetching the PGN so it works during the post-game cleanup window

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 19:14:39 -06:00
Josh-HeapsandClaude Opus 4.8 d87cea572f Feed move history and halfmove clock to the engine for repetition detection
- Track HalfmoveClock in GameState/ChessService (reset on captures and pawn moves)
- Emit the real halfmove clock in ToFen and add GameState.RepetitionHistory()
  (prior positions since the last irreversible move) in ChessEngineHelpers
- Pass that history through IChessEngine.GetBestMoveAsync to the native engine;
  Stockfish ignores it

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:59:10 -06:00
Josh-HeapsandClaude Opus 4.8 5345427e0c Add transposition table, iterative deepening, and repetition-aware search
- Refactor the search to negamax (single side-relative score + alpha/beta window)
- Add a shared, process-wide transposition table: lock-free XOR-verified slots,
  persists across games, with depth-gated and difficulty-capped score reuse so a
  weak bot can't borrow a stronger game's deeper analysis
- Drive the search with iterative deepening, seeding each depth's move ordering
  from the previous one
- Seed prior-position history (Position::seed_history) over a new engine_best_move
  history parameter so the engine detects threefold/50-move draws the FEN can't carry

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:58:59 -06:00
Josh-Heaps 76b054ee9d More efficient searching 2026-06-08 17:28:04 -06:00
Josh-HeapsandClaude Opus 4.8 2db95cec34 Add spectator watch page, forfeit-on-new-game, CPU self-play, and threefold repetition
- /watch live feed of active games with click-to-fullscreen and per-game back button
- Starting a new game forfeits the in-progress game; opponent is flagged the winner
- Watch CPU vs CPU self-play games; results shown for ~30s before cleanup
- Detect threefold repetition in the C# rules layer and end the game as a draw

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 16:21:49 -06:00
Josh Heaps 9b69489c4e Merge pull request #17 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 14:17:52 -06:00
Josh-HeapsandClaude Opus 4.8 fc2a2e122b Build libchess_engine.so in CI deploy and pin runner to ubuntu-24.04
Compile the native engine and copy it into Resources/ before publish so it
ships to prod automatically. Runner pinned to ubuntu-24.04 to match the
server's glibc.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 14:12:50 -06:00
Josh-Heaps c3280aa6d3 add custom chess engine 2026-06-08 13:58:22 -06:00
Josh Heaps d2ae34f530 Merge pull request #16 from JoshHeaps/feature/blog
Update blog css
2026-03-07 12:27:39 -07:00
Josh-Heaps 52d468a46e Update blog css 2026-03-07 12:26:58 -07:00
Josh Heaps 7fcfb270a1 Merge pull request #15 from JoshHeaps/feature/blog
fix css
2026-03-06 17:16:19 -07:00
Josh-Heaps ea60ee5a4b fix css 2026-03-06 17:15:51 -07:00
Josh Heaps 3dc1bc452e Merge pull request #14 from JoshHeaps/feature/blog
Add cache invalidation
2026-03-06 17:08:41 -07:00
Josh-Heaps 99a9474ef7 Add cache invalidation 2026-03-06 17:05:48 -07:00
Josh Heaps 4884c32fc8 Merge pull request #13 from JoshHeaps/feature/blog
Replace file-based blog with API-backed service
2026-03-06 15:44:44 -07:00
Josh-HeapsandClaude Opus 4.6 8043eee4cc Replace file-based blog with API-backed service
BlogService now fetches from Media.JoshHeaps.Net API via HttpClient
with in-memory caching (5-min TTL) and stale cache fallback.
Removed Markdig/YamlDotNet dependencies. All page models now async.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:51 -07:00
Josh Heaps 5fd3a5d7c1 Merge pull request #12 from JoshHeaps/AddProject
Add cloud storage to projects section of front page
2025-10-22 11:58:33 -06:00
Josh-Heaps 9969c6e3cf Add cloud storage to projects section of front page 2025-10-22 11:54:48 -06:00
Josh Heaps 0788015cb0 Merge pull request #11 from JoshHeaps/feature/generalUpdate
Feature/general update
2025-10-17 13:06:02 -06:00
jheaps 34de3c1abc Update href for chess github 2025-10-17 12:57:17 -06:00
jheaps 311d62a719 Update images 2025-10-17 12:48:10 -06:00
jheaps 4217bf3703 Fix headless issue 2025-10-17 12:29:49 -06:00
jheaps fd68ada82e Get fancy wit it 2025-10-17 12:14:37 -06:00
jheaps 7c77cd0e52 Add square numbers and bug fixes 2025-10-17 12:10:08 -06:00
jheaps 93bdb3518a Bug fixes 2025-10-17 11:41:50 -06:00
Josh Heaps 69e1e8450d Merge pull request #10 from JoshHeaps/tech-debt/chess-cleanup
Add demo button for media.joshheaps.net
2025-10-13 10:18:17 -06:00
jheaps 09b408a75b Add demo button for media.joshheaps.net 2025-10-13 10:15:44 -06:00
Josh Heaps e74de40d2e Merge pull request #9 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-03 11:27:23 -06:00
jheaps 03dd308449 game removal code cleanup 2025-10-03 11:24:02 -06:00
jheaps 54e4f25be7 Styling 2025-10-03 10:45:20 -06:00
jheaps c2622132bc Revert site back to normal, add memory lane 2025-10-03 10:43:15 -06:00
jheaps 9bd7eb283e Remove bad test 2025-10-03 10:42:57 -06:00
Josh Heaps 9b86bfafe0 Merge pull request #8 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-02 10:19:41 -06:00
jheaps 95b4ae63ea Anniversary page <3 2025-10-02 10:16:05 -06:00
jheaps edab13f8e4 Javascript cleanup 2025-09-25 10:46:24 -06:00
Josh Heaps ea78302041 Merge pull request #7 from JoshHeaps/tests/CreatePlaywrightTests
Add playwright tests with github actions
2025-09-24 14:43:01 -06:00
Josh Heaps 3b91055666 Merge pull request #6 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-09-19 16:31:17 -06:00
jheaps b4acdee6e3 Fix auto ip updates 2025-09-19 16:29:44 -06:00
jheaps 10bf9b3d30 Bring debug.js back (I don't remember killing it) 2025-09-19 16:09:47 -06:00
Josh Heaps a3b384b221 Merge pull request #5 from JoshHeaps/UpdateParticleSimulatorButton
remove button to show off github actions
2025-09-02 11:20:47 -06:00
jheaps be0a753945 remove button to show off github actions 2025-09-02 11:18:44 -06:00
Josh Heaps eb3d15527a Merge pull request #4 from JoshHeaps/UpdateParticleSimulatorButton
aewpoifhjp
2025-09-02 11:15:27 -06:00
jheaps bb0d611b21 aewpoifhjp 2025-09-02 11:14:49 -06:00
Josh Heaps 636f133288 Merge pull request #3 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-08-29 16:31:21 -06:00
jheaps 847f5cc970 Update button sizing 2025-08-29 16:30:18 -06:00
jheaps 75d4ee8f9a Update button text 2025-08-29 16:28:35 -06:00
jheaps d0272adb51 stop running build twice after push 2025-08-29 16:27:37 -06:00
119 changed files with 9302 additions and 666 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(dotnet test:*)" "Bash(dotnet test:*)",
"Bash(dotnet build)",
"Bash(dir:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+21 -4
View File
@@ -14,7 +14,9 @@ concurrency:
jobs: jobs:
build: 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -24,9 +26,16 @@ jobs:
with: with:
dotnet-version: '8.0.x' # adjust if needed dotnet-version: '8.0.x' # adjust if needed
- name: Restore - 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 - name: Publish
run: dotnet publish -c Release -o ./publish run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -45,6 +54,12 @@ jobs:
name: site-publish name: site-publish
path: 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 - name: Prepare SSH
run: | run: |
install -m 700 -d ~/.ssh install -m 700 -d ~/.ssh
@@ -54,7 +69,9 @@ jobs:
- name: Rsync to server - name: Rsync to server
run: | 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 }}/ publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
- name: Reload and restart service - name: Reload and restart service
+2 -4
View File
@@ -4,8 +4,6 @@
name: .NET name: .NET
on: on:
push:
branches: [ "master" ]
pull_request: pull_request:
branches: [ "master" ] branches: [ "master" ]
@@ -21,6 +19,6 @@ jobs:
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore JoshHeaps.Net.slnf
- name: Build - name: Build
run: dotnet build --no-restore run: dotnet build JoshHeaps.Net.slnf --no-restore
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore JoshHeaps.Net.slnf
- name: Build solution - name: Build solution
run: dotnet build --no-restore run: dotnet build JoshHeaps.Net.slnf --no-restore
- name: Install Playwright browsers - name: Install Playwright browsers
run: | run: |
+6
View File
@@ -3,6 +3,9 @@
## ##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## 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 # User-specific files
*.rsuser *.rsuser
*.suo *.suo
@@ -361,3 +364,6 @@ MigrationBackup/
# Fody - auto-generated XML schema # 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/
+13
View File
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
private IAPIRequestContext? _apiContext; private IAPIRequestContext? _apiContext;
private TestConfiguration Config => TestConfiguration.Instance; private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
if (!Config.Playwright.Headless)
{
Environment.SetEnvironmentVariable("HEADED", "1");
}
if (Config.Playwright.SlowMotion > 0)
{
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
}
}
[SetUp] [SetUp]
public async Task Setup() public async Task Setup()
{ {
-14
View File
@@ -81,18 +81,4 @@ public class MultiplayerTests : PageTest
// Should see SignalR connected message in console // Should see SignalR connected message in console
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected")); Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
} }
[Test]
public async Task Move_Updates_Are_Sent_Via_SignalR()
{
// This would require more complex setup with two players
// For now, just verify the SignalR methods exist in the JavaScript
var jsContent = await Page.EvaluateAsync<string>(@"
() => {
return document.querySelector('script[src*=""chessLogic.js""]') ? 'chessLogic.js loaded' : 'not found';
}
");
Assert.That(jsContent, Is.EqualTo("chessLogic.js loaded"));
}
} }
+36 -2
View File
@@ -1,26 +1,60 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.9.34728.123 VisualStudioVersion = 18.6.11822.322 stable
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
EndProject EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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|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.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+9
View File
@@ -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();
}
}
+197 -95
View File
@@ -1,60 +1,73 @@
using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Controllers; namespace JoshHeaps.Net.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase public class ChessController(
IChessService chessService,
IBackgroundTaskQueue queue,
IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IGameStore gameStore,
ISelfPlayCoordinator selfPlay,
AutoTrainingSettings autoTraining,
IHubContext<ChessHub> chessHub) : ControllerBase
{ {
/// <summary> private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
/// Store of ongoing games. private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
/// </summary> private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
/// <summary> /// <summary>
/// Create a new chess game and store it in-memory. /// Create a new chess game and store it in-memory.
/// </summary> /// </summary>
[HttpGet("new")] [HttpGet("new")]
[HttpGet("new/{difficulty}")] [HttpGet("new/{difficulty}")]
public ActionResult CreateGame(int difficulty = 20) public ActionResult CreateGame(int difficulty = 20, string color = "random")
{ {
var gameState = chessService.CreateNewGame(); var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; gameStore.Add(gameState);
gameState.IsVsComputer = true; gameState.IsVsComputer = true;
gameState.WhiteJoined = true; gameState.WhiteJoined = true;
gameState.BlackJoined = true; gameState.BlackJoined = true;
Guid playerId = Guid.NewGuid(); Guid playerId = Guid.NewGuid();
Guid computerId = 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) if (isWhite)
{ {
gameState.WhitePlayerId = playerId; gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId; gameState.BlackPlayerId = computerId;
gameState.BlackComputer = computer;
} }
else else
{ {
gameState.WhitePlayerId = computerId; gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId; gameState.BlackPlayerId = playerId;
gameState.WhiteComputer = computer;
queue.Queue(async () => queue.Queue(async () =>
{ {
// Give user's browser time to connect to signalR and such. // Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1)); await Task.Delay(TimeSpan.FromSeconds(1));
await gameState.Computer.MakeMove(gameState, chessHub, chessService); await orchestrator.PlayAsync(gameState);
}); });
} }
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1)); gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -64,6 +77,55 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
}); });
} }
/// <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> /// <summary>
/// Joins the "pool" of chess players. /// Joins the "pool" of chess players.
/// Test code expects to receive a GUID for the player /// Test code expects to receive a GUID for the player
@@ -73,12 +135,12 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
public ActionResult JoinGame() public ActionResult JoinGame()
{ {
Console.WriteLine("joining game"); Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen); GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
if (gameState == null) if (gameState == null)
{ {
gameState = chessService.CreateNewGame(); gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; gameStore.Add(gameState);
} }
Guid playerId = Guid.NewGuid(); Guid playerId = Guid.NewGuid();
@@ -96,7 +158,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
isWhite = false; isWhite = false;
} }
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1)); gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -106,41 +168,62 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
}); });
} }
/// <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> /// <summary>
/// Get the state of an existing game by ID. /// Get the state of an existing game by ID.
/// </summary> /// </summary>
[HttpGet("{gameId}")] [HttpGet("{gameId}")]
public ActionResult GetGameState(Guid 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"); return NotFound("Game not found");
var response = new return Ok(gameState.ToDto());
{
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);
} }
/// <summary> /// <summary>
@@ -148,9 +231,9 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
/// The test passes a JSON body with a MoveDto. /// The test passes a JSON body with a MoveDto.
/// </summary> /// </summary>
[HttpPost("move")] [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"); return NotFound("Game not found");
// Check if player is authorized to move // Check if player is authorized to move
@@ -158,7 +241,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId; var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
if (moveDto.PlayerId != expectedPlayerId) if (moveDto.PlayerId != expectedPlayerId)
return Forbid("You are not the current player."); return StatusCode(403, "You are not the current player.");
// Make sure player owns the piece // Make sure player owns the piece
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId); var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
@@ -167,31 +250,80 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
return NotFound("Chess piece Id does not exist"); return NotFound("Chess piece Id does not exist");
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black)) if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
return Forbid("You cannot move this piece."); return StatusCode(403, "You cannot move this piece.");
var result = chessService.MakeMove(gameState, moveDto); var result = chessService.MakeMove(gameState, moveDto);
if (!result.Success) if (!result.Success)
return BadRequest(result); return BadRequest(result);
if (result.IsCheckmate || result.IsStalemate) var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
{
// queue game removal if (isGameOver)
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1)); gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
} else if (gameState.IsVsComputer)
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
else else
{ gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
// increase timeout if play continues.
if (gameState.IsVsComputer) var state = gameState.ToDto();
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
else // Broadcast the move (with the full resulting state) to everyone watching this
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1)); // 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 });
} }
if (gameState.IsVsComputer && gameState.Computer is not null) /// <summary>
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService)); /// 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");
return Ok(result); 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> /// <summary>
@@ -200,7 +332,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
[HttpGet("{gameId}/legalMoves/{pieceId}")] [HttpGet("{gameId}/legalMoves/{pieceId}")]
public ActionResult GetLegalMoves(Guid gameId, string 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"); return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId); var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
@@ -214,7 +346,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
[HttpGet("{gameId}/legalMoves")] [HttpGet("{gameId}/legalMoves")]
public ActionResult GetAllLegalMoves(Guid gameId) public ActionResult GetAllLegalMoves(Guid gameId)
{ {
if (!_games.TryGetValue(gameId, out var gameState)) if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found"); return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState) var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -226,34 +358,4 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
return Ok(allMoves); return Ok(allMoves);
} }
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{
if (_gameRemovalTasks.ContainsKey(id))
{
_gameRemovalTasks[id] = Task.Run(async () =>
{
await Task.Delay(delay);
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
});
return;
}
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
{
await Task.Delay(delay);
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
}));
}
} }
+1 -7
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Models; using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs; namespace JoshHeaps.Net.Hubs;
@@ -11,11 +10,6 @@ public class ChessHub : Hub
await Groups.AddToGroupAsync(Context.ConnectionId, gameId); 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) public async Task LeaveWebsocketGroup(string gameId)
{ {
Console.WriteLine($"❌ Leaving group {gameId}"); Console.WriteLine($"❌ Leaving group {gameId}");
+14
View File
@@ -4,6 +4,8 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -27,4 +29,16 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
</ItemGroup> </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> </Project>
+12
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
namespace JoshHeaps.Net.Models;
public class ForfeitDto
{
public Guid GameId { get; set; }
public Guid PlayerId { get; set; }
}
+33 -1
View File
@@ -1,4 +1,5 @@
using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models; namespace JoshHeaps.Net.Models;
@@ -28,10 +29,25 @@ public class GameState
public bool IsCheck { get; set; } public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; } public bool IsCheckmate { get; set; }
public bool IsStalemate { 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 // Keep a history of moves if desired
public List<string> MoveHistory { get; set; } 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). // A list of all pieces to quickly reference them (optional but convenient).
// Alternatively, you can iterate the Board array. // Alternatively, you can iterate the Board array.
public List<ChessPiece> Pieces { get; set; } public List<ChessPiece> Pieces { get; set; }
@@ -42,8 +58,22 @@ public class GameState
public Guid BlackPlayerId { get; set; } public Guid BlackPlayerId { get; set; }
public bool IsVsComputer { get; set; } = false; 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 // optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined; public bool IsOpen => !WhiteJoined || !BlackJoined;
@@ -54,5 +84,7 @@ public class GameState
Board = new ChessPiece[8, 8]; Board = new ChessPiece[8, 8];
Pieces = []; Pieces = [];
MoveHistory = []; MoveHistory = [];
PositionHistory = [];
SanHistory = [];
} }
} }
+61
View File
@@ -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);
+1
View File
@@ -7,4 +7,5 @@ public class MoveResultDto
public bool IsCheck { get; set; } public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; } public bool IsCheckmate { get; set; }
public bool IsStalemate { get; set; } public bool IsStalemate { get; set; }
public bool IsThreefoldRepetition { get; set; }
} }
+49
View File
@@ -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" />
}
+26
View File
@@ -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();
}
}
+38
View File
@@ -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">&larr; 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>
}
+28
View File
@@ -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();
}
}
+59 -8
View File
@@ -6,7 +6,14 @@
} }
<div id="chessContainer"> <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"> <div id="chessBoard">
<!-- Placeholder squares --> <!-- Placeholder squares -->
@for (int i = 0; i < 64; i++) @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 id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
} }
</div> </div>
</div>
<div id="textContainer" class="sideContent"> <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>
<aside id="gamePanel">
<header class="panelHeader">
<span class="panelLogo">&#9822;</span>
<h1>Chess</h1> <h1>Chess</h1>
<p>Click a button to start a game :)</p> <button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">&times;</button>
</header>
<div id="moveList">
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
</div> </div>
<div id="buttonContainer" class="sideContent"> <div id="statusLine">Start a game to play.</div>
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button> <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 &rarr;</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>
</div> </div>
@@ -57,12 +89,31 @@
</div> </div>
</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 { @section Scripts {
<script src="~/js/signalr/signalr.min.js"></script> <script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/chessLogic.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 { @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/game.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" /> <link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
} }
+75 -54
View File
@@ -1,95 +1,116 @@
@page @page
@model JoshHeaps.Net.Pages.IndexModel @model JoshHeaps.Net.Pages.IndexModel
@{ @{
ViewData["Title"] = "JoshHeaps.Net"; ViewData["Title"] = "JoshHeaps.Net";
Layout = "_Layout"; Layout = "_Layout";
} }
<header id="Header"> <nav id="Header">
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a> <span class="site-name">josh heaps</span>
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a> <div class="nav-links">
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a> <a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
</header> <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>
<section id="Hero">
<div id="WelcomeMessage"></div> <div id="WelcomeMessage"></div>
</section>
<div id="ProjectsBox"> <section id="ProjectsBox">
<div id="ChessProject" class="projectContainer"> <h2 class="section-title">Projects</h2>
<div class="displayBox diagonal-section-left"> <div class="projects-grid">
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess"> <article class="project-card">
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/> <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> </a>
<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 &rarr;</a>
</div> </div>
<div id="ChessText" class="projectTextRight"> </article>
<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. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
</p>
</div>
</div>
<div id="CompilerProject" class="projectContainer"> <article class="project-card">
<div id="CompilerText" class="projectTextLeft"> <a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2> <img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
<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"/>
</a> </a>
<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 &rarr;</a>
</div> </div>
</div> </article>
</div>
<div id="DemosBox" class="displayBox"> <article class="project-card">
<h2 id="DemoHeader">Demos</h2> <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 &rarr;</a>
</div>
</article>
</div>
</section>
<section id="DemosBox">
<h2 class="section-title">Demos</h2>
<div id="buttonWrapper"> <div id="buttonWrapper">
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'"> <button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
Play Chess <button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
</button> <button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/particles'"> <button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
Life <button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
</button> <button id="MoreFiller" class="demoButton">More coming soon...</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>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &rarr;</a>
</section>
<footer id="Contacts"> <footer id="Contacts">
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<svg viewBox="0 0 16 16" class="social-icon"> <svg viewBox="0 0 16 16" class="social-icon">
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<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> <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>
</a>
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<svg viewBox="0 0 24 24" class="social-icon"> <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">
<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> <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>
</a>
<a class="social-link" href="mailto:[email protected]">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none"> <svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
<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="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="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="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="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"/> <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> </footer>
@section Styles { @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/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/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/blog.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/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
} }
+8 -3
View File
@@ -1,12 +1,17 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages 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();
} }
} }
} }
+54
View File
@@ -0,0 +1,54 @@
@page
@model JoshHeaps.Net.Pages.MemoryLaneModel
@{
ViewData["Title"] = "Memory Lane";
Layout = "_Layout";
}
<div class="memory-lane-container">
<h1 class="memory-lane-header">Memory Lane</h1>
<p class="memory-lane-subtitle">Special moments and celebrations from our life together</p>
<div class="events-grid">
<a href="/memoryLane/fifthTempleAnniversary" class="event-card">
<div class="event-icon">💝</div>
<div class="event-title">Our 5th Sealing Anniversary</div>
<div class="event-description">
Celebrating the fifth year of our eternal journey together
</div>
</a>
<div class="event-card disabled">
<div class="event-icon">🎂</div>
<div class="event-title">Birthdays & Births</div>
<div class="event-description">
Welcoming new life and celebrating another year
</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="event-card disabled">
<div class="event-icon">🌟</div>
<div class="event-title">Milestones</div>
<div class="event-description">
Big achievements and unforgettable moments
</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="event-card disabled">
<div class="event-icon">✨</div>
<div class="event-title">More Memories</div>
<div class="event-description">
Even more special occasions and celebrations
</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/memoryLane/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class MemoryLaneModel() : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,69 @@
@page
@model JoshHeaps.Net.Pages.MemoryLane.FifthTempleAnniversary
@{
ViewData["Title"] = "Happy 5th Anniversary, Morgan!";
Layout = "_Layout";
}
<div class="anniversary-container">
<h1 class="anniversary-header">Happy 5th Anniversary, Morgan!</h1>
<div id="WelcomeMessage"></div>
<div class="hearts">💖 💕 💗</div>
<div class="photo-gallery">
<div class="photo-frame">
<div class="photo-wrapper">
<img src="/images/Wife Images/45053.jpeg" alt="Morgan with our beautiful baby">
</div>
<div class="photo-caption">The most amazing mother to our precious child</div>
</div>
<div class="photo-frame">
<div class="photo-wrapper">
<img src="/images/Wife Images/45054.jpeg" alt="Josh and Morgan together">
</div>
<div class="photo-caption">My best friend and eternal companion</div>
</div>
</div>
<div class="love-message">
<h2>Five Years of Forever</h2>
<p>
Five years ago today, on October 2nd, 2020, I knelt across the altar from you in the temple
and we were sealed for time and all eternity. It was the best decision I ever made.
</p>
<p>
Morgan, you are everything I could have ever hoped for and so much more. You're not just
my wife—you're my best friend, my partner in every adventure, and the love of my life.
</p>
<p>
You make me laugh every single day. Your kindness touches everyone around you. Your
strength amazes me. Your beauty, inside and out, takes my breath away. Watching you
become a mother has shown me depths of love and dedication I never knew existed.
</p>
<p>
Thank you for five incredible years of love, laughter, growth, and joy. Thank you for
choosing me, for believing in me, and for building this beautiful life with me.
</p>
<p>
Here's to five years down and an eternity to go. I love you more today than yesterday,
and I'll love you even more tomorrow.
</p>
<p style="font-size: 1.5rem; margin-top: 2rem; color: #764ba2;">
<strong>I love you, Morgan. Forever and always. 💖</strong>
</p>
</div>
<div class="hearts">💝 💞 💓</div>
</div>
@section Styles {
<link href="/css/anniversary/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="/js/anniversary/page.js"></script>
<script src="/js/utils.js"></script>
}
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.MemoryLane
{
public class FifthTempleAnniversary() : PageModel
{
public void OnGet()
{
}
}
}
+61
View File
@@ -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"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WatchModel : PageModel
{
public void OnGet()
{
}
}
}
+44
View File
@@ -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"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WeightsModel : PageModel
{
public void OnGet()
{
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
@{ @{
Layout = null; Layout = null;
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
} }
<!DOCTYPE html> <!DOCTYPE html>
+22
View File
@@ -12,12 +12,34 @@ builder.Services.AddControllers();
builder.Services.AddSignalR(); 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<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>(); 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()) if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService<AutoIpUpdateService>(); 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(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
@@ -11,6 +11,7 @@
"profiles": { "profiles": {
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "http://localhost:5200", "applicationUrl": "http://localhost:5200",
@@ -20,6 +21,7 @@
}, },
"https": { "https": {
"commandName": "Project", "commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "https://localhost:7118;http://localhost:5200", "applicationUrl": "https://localhost:7118;http://localhost:5200",
Binary file not shown.
@@ -1,35 +1,30 @@
namespace JoshHeaps.Net.Services.Implementations; using System.Text.Json;
using System.Text.Json.Serialization;
namespace JoshHeaps.Net.Services.Implementations;
public class AutoIpUpdateService( public class AutoIpUpdateService(
IConfiguration config, IConfiguration config,
ILogger<AutoIpUpdateService> log) ILogger<AutoIpUpdateService> log)
: BackgroundService : BackgroundService
{ {
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
private static readonly HttpClient httpClient = new();
public static bool IsEnabled { get; private set; } = false; public static bool IsEnabled { get; private set; } = false;
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
private static readonly HttpClient _httpClient = new();
protected override async Task ExecuteAsync(CancellationToken stop) protected override async Task ExecuteAsync(CancellationToken stop)
{ {
IsEnabled = true; IsEnabled = true;
var timer = new PeriodicTimer(CheckInterval); var timer = new PeriodicTimer(_checkInterval);
AAAARecord dnsRecord = await GetDnsRecordAsync();
string lastKnownIp = dnsRecord.Content;
while (await timer.WaitForNextTickAsync(stop)) while (await timer.WaitForNextTickAsync(stop))
{ {
try try
{ {
string currentIp = await GetPublicIpAsync() ?? ""; await UpdateIpAddressIfChanged();
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
} }
} catch (OperationCanceledException) { break; }
catch (OperationCanceledException) { /* shutting down */ }
catch (Exception ex) catch (Exception ex)
{ {
log.LogError(ex, "Error while attempting ip update"); log.LogError(ex, "Error while attempting ip update");
@@ -37,11 +32,33 @@ public class AutoIpUpdateService(
} }
} }
private async Task UpdateIpAddressIfChanged()
{
var dnsRecords = await GetDnsRecordAsync();
string dnsRecordIp = dnsRecords[0].Content;
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
dnsRecordIp = string.Empty;
string publicIp = await GetPublicIpAsync() ?? string.Empty;
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
return;
foreach (var dnsRecord in dnsRecords.Records)
{
if (dnsRecord.Content == publicIp)
continue;
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
}
}
private static async Task<string> GetPublicIpAsync() private static async Task<string> GetPublicIpAsync()
{ {
try try
{ {
return await httpClient.GetStringAsync(@"https://api.ipify.org/"); return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
} }
catch catch
{ {
@@ -52,7 +69,7 @@ public class AutoIpUpdateService(
} }
} }
private async Task<AAAARecord> GetDnsRecordAsync() private async Task<RecordList> GetDnsRecordAsync()
{ {
try try
{ {
@@ -61,8 +78,8 @@ public class AutoIpUpdateService(
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records"); var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync()); Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync()); var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.Result[0]; return records!;
} }
catch catch
{ {
@@ -73,22 +90,23 @@ public class AutoIpUpdateService(
} }
} }
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip) private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
{ {
HttpClient cfClient = new(); HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
object content = new
object updateRequestBody = new
{ {
comment = "Update as needed", name = record.Name,
ttl = record.Ttl,
type = record.Type,
comment = record.Comment,
content = ip, content = ip,
name = "@", proxied = record.Proxied,
proxied = true,
ttl = 3600,
type = "AAAA"
}; };
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content); var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
if (!result.IsSuccessStatusCode) if (!result.IsSuccessStatusCode)
{ {
@@ -99,7 +117,23 @@ public class AutoIpUpdateService(
} }
} }
record AAAARecord(string Comment, string Content, string Name, string Id); record DnsRecord(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ttl")] int Ttl,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("comment")] string Comment,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("proxied")] bool Proxied,
[property: JsonPropertyName("id")] string Id);
record RecordList(List<AAAARecord> Result); record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
{
public DnsRecord this[int index]
{
get
{
return Records[index];
}
}
}
} }
@@ -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,94 @@
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 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
{
if (_cache.TryGetValue(key, out var entry) && 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));
}
else if (entry is not null)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
}
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.Pieces.Clear();
gameState.MoveHistory.Clear(); gameState.MoveHistory.Clear();
gameState.SanHistory.Clear();
gameState.WhiteCanCastleKingside = true; gameState.WhiteCanCastleKingside = true;
gameState.WhiteCanCastleQueenside = true; gameState.WhiteCanCastleQueenside = true;
gameState.BlackCanCastleKingside = true; gameState.BlackCanCastleKingside = true;
gameState.BlackCanCastleQueenside = true; gameState.BlackCanCastleQueenside = true;
gameState.EnPassantTarget = null; gameState.EnPassantTarget = null;
gameState.HalfmoveClock = 0;
SetupBlackPieces(gameState); SetupBlackPieces(gameState);
SetupWhitePieces(gameState); SetupWhitePieces(gameState);
@@ -35,6 +37,9 @@ public class ChessService : IChessService
gameState.CurrentPlayer = PieceColor.White; gameState.CurrentPlayer = PieceColor.White;
UpdateCheckStatus(gameState); UpdateCheckStatus(gameState);
gameState.PositionHistory.Clear();
gameState.PositionHistory.Add(PositionKey(gameState));
} }
private static void SetupBlackPieces(GameState gs) private static void SetupBlackPieces(GameState gs)
@@ -110,7 +115,6 @@ public class ChessService : IChessService
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId); var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return []; if (piece == null) return [];
if (piece.Color != gameState.CurrentPlayer) return [];
var candidateMoves = GenerateCandidateMoves(gameState, piece); var candidateMoves = GenerateCandidateMoves(gameState, piece);
var legalMoves = new List<Position>(); var legalMoves = new List<Position>();
@@ -138,12 +142,23 @@ public class ChessService : IChessService
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol)) if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
return new MoveResultDto { Success = false, Message = "Illegal move." }; 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); PerformMove(gameState, piece, targetPos, moveDto);
UpdateCheckStatus(gameState); UpdateCheckStatus(gameState);
var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
gameState.MoveHistory.Add(notation); 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 return new MoveResultDto
{ {
@@ -151,14 +166,95 @@ public class ChessService : IChessService
Message = "Move successful.", Message = "Move successful.",
IsCheck = gameState.IsCheck, IsCheck = gameState.IsCheck,
IsCheckmate = gameState.IsCheckmate, 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) private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
{ {
var oldPos = piece.Position; var oldPos = piece.Position;
var captured = gs.Board[targetPos.Row, targetPos.Col]; 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); HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
@@ -180,6 +276,9 @@ public class ChessService : IChessService
UpdateCastlingRights(gs, piece, oldPos); 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 gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
? PieceColor.Black ? PieceColor.Black
: PieceColor.White; : PieceColor.White;
@@ -529,8 +628,15 @@ public class ChessService : IChessService
var ep = gs.EnPassantTarget.Value; var ep = gs.EnPassantTarget.Value;
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1) if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
{
// Verify there's an enemy pawn to capture
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
moves.Add(ep); moves.Add(ep);
} }
}
return moves; return moves;
} }
@@ -542,11 +648,7 @@ public class ChessService : IChessService
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]); => GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece) private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
=> GenerateSlidingMoves(gs, piece, => [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
[
(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1)
]);
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions) private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
{ {
@@ -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 JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using System.Diagnostics; using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels; using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations; namespace JoshHeaps.Net.Services.Implementations;
public sealed class Stockfish : IAsyncDisposable public sealed class Stockfish : IChessEngine
{ {
private readonly Process _p; private readonly Process _p;
private readonly StreamWriter _stdin; private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>(); private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill; private readonly int _skill;
public int Skill => _skill;
public Stockfish(int skill = 20, int hash = 256) public Stockfish(int skill = 20, int hash = 256)
{ {
_skill = skill; _skill = skill;
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
WaitFor("readyok").GetAwaiter().GetResult(); 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($"position fen {fen}");
Send($"go depth {_skill}"); Send($"go depth {_skill}");
string? best = null; string? best = null;
@@ -109,145 +107,4 @@ public sealed class Stockfish : IAsyncDisposable
await _p.WaitForExitAsync(); await _p.WaitForExitAsync();
_p.Dispose(); _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
{
'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 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('/');
}
/* 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();
}
/* ---------- 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);
}
@@ -5,5 +5,8 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"ChessEngine": {
"Engine": "Custom"
} }
} }
+8 -1
View File
@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"BlogApi": {
"BaseUrl": "https://media.joshheaps.net",
"InvalidateKey": "CHANGE_ME"
},
"ChessEngine": {
"Engine": "Custom"
}
} }
@@ -0,0 +1,218 @@
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
margin: 0;
padding: 0;
}
.anniversary-container {
max-width: 1400px;
margin: 0 auto;
padding: 3rem 2rem;
text-align: center;
}
.anniversary-header {
color: white;
font-size: 3.5rem;
margin-bottom: 1.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
animation: fadeInDown 1s ease-out;
}
#WelcomeMessage {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2rem 2.5rem;
margin: 0 auto 2rem;
max-width: 900px;
font-size: 1.3rem;
line-height: 1.8;
color: #333;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: fadeIn 2s ease-out;
}
.hearts {
font-size: 2.5rem;
margin: 1.5rem 0;
animation: heartbeat 1.5s ease-in-out infinite;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin: 2rem auto;
max-width: 1200px;
padding: 0 1rem;
align-items: start;
}
.photo-frame {
background: white;
padding: 1rem;
border-radius: 15px;
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
animation: fadeInUp 1.5s ease-out;
display: flex;
flex-direction: column;
}
.photo-frame:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
}
.photo-wrapper {
width: 100%;
height: 500px;
overflow: hidden;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
}
.photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.photo-frame:nth-child(2) img {
object-position: center 30%;
}
.photo-caption {
margin-top: 1rem;
font-style: italic;
color: #666;
font-size: 1.1rem;
padding: 0.5rem;
}
.love-message {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2.5rem 3rem;
margin: 2rem auto;
max-width: 1000px;
color: #444;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: fadeIn 2.5s ease-out;
}
.love-message h2 {
color: #764ba2;
margin-bottom: 1.5rem;
font-size: 2.5rem;
}
.love-message p {
font-size: 1.2rem;
line-height: 1.9;
margin-bottom: 1.2rem;
text-align: left;
}
.love-message p:last-of-type {
text-align: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@media (max-width: 900px) {
.photo-gallery {
grid-template-columns: 1fr;
max-width: 600px;
}
.photo-wrapper {
height: 450px;
}
}
@media (max-width: 768px) {
.anniversary-container {
padding: 2rem 1rem;
}
.anniversary-header {
font-size: 2.2rem;
}
#WelcomeMessage {
font-size: 1.1rem;
padding: 1.5rem;
}
.photo-wrapper {
height: 400px;
}
.love-message {
padding: 1.5rem;
}
.love-message h2 {
font-size: 1.8rem;
}
.love-message p {
font-size: 1rem;
}
.hearts {
font-size: 2rem;
}
}
+126
View File
@@ -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);
}
+184
View File
@@ -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;
}
+67 -3
View File
@@ -1,7 +1,4 @@
#chessBoard { #chessBoard {
margin: 2vw auto;
width: 60vw;
height: 60vw;
display: grid; display: grid;
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
@@ -159,3 +156,70 @@
height: 50px; height: 50px;
pointer-events: none; /* ensures img doesn't steal the click */ 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;
font-weight: bold;
pointer-events: none;
user-select: none;
}
.chessSquare.light .coordinate-label {
color: #656770;
}
.chessSquare.dark .coordinate-label {
color: #ccc;
}
.chessSquare .row-label {
top: 2px;
left: 4px;
}
.chessSquare .col-label {
bottom: 2px;
right: 4px;
}
+395 -103
View File
@@ -1,137 +1,429 @@
html, body { :root {
background-color: #2b2c30; --bg: #2b2c30;
color: #d6d6d6; --panel: #21232a;
cursor: default; --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%; height: 100%;
margin: 0; 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 { main {
background-color: #8cd5ed; height: 100%;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
} }
#startCPUGame { /* ---- Layout ------------------------------------------------------------ */
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
/* Portrait / narrow: board on top, panel stacked below. */
#chessContainer { #chessContainer {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-evenly; gap: clamp(8px, 1.6vh, 16px);
height: 100vh; padding: clamp(10px, 2vh, 20px);
box-sizing: border-box; overflow: hidden;
padding: 5vw; }
#boardArea {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
width: min(94vw, 56vh);
} }
#chessBoard { #chessBoard {
aspect-ratio: 1 / 1; width: 100%;
width: 90vw; /* use the smaller of width or height */
height: auto; 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 { #gamePanel {
align-content: center; width: min(94vw, 56vh);
flex-grow: 1; flex: 1 1 auto;
} min-height: 0;
.sideContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-content: center; background: var(--panel);
justify-content: center; border-radius: var(--radius);
text-align: center; overflow: hidden;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
} }
#textContainer { /* Landscape / wide: board left, game panel right (chess.com style). */
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 */
@media (min-aspect-ratio: 1/1) { @media (min-aspect-ratio: 1/1) {
#chessContainer { #chessContainer {
display: grid;
grid-template-columns: auto, auto;
grid-template-rows: auto, auto;
align-items: center;
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: center; justify-content: center;
align-items: center; gap: clamp(16px, 3vw, 48px);
height: 100vh; padding: clamp(12px, 3vh, 28px);
padding: 0;
} }
.sideContent { #boardArea {
display: flex; width: auto;
flex-direction: column; height: 100%;
flex-grow: 1; justify-content: center;
flex-shrink: 0; flex: 0 0 auto;
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;
} }
#chessBoard { #chessBoard {
aspect-ratio: 1 / 1; width: min(72vh, 54vw);
flex-shrink: 1; height: min(72vh, 54vw);
width: min(90vh, 90vw);
max-height: 90vh;
} }
#difficultyButtonContainer { .playerBar {
grid-template-columns: repeat(10, 1fr); 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);
}
+187
View File
@@ -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;
}
+65
View File
@@ -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);
}
+18 -10
View File
@@ -1,17 +1,25 @@
#Contacts { /* ── Footer / Contacts ── */
margin-top: 20vw;
#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 { .social-icon {
height: auto; height: auto;
width: 3.5vw; width: clamp(1.25rem, 2.5vw, 1.75rem);
margin-bottom: 5vw; fill: var(--color-text-subtle);
margin-left: 3vw; transition: fill 0.2s;
fill: rgb(212, 212, 212);
transition: fill 0.5s;
} }
.social-icon:hover { .social-link:hover .social-icon {
fill: white; fill: var(--color-heading-secondary);
transition: fill 0.5s;
} }
@@ -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;
}
+77 -16
View File
@@ -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; display: grid;
grid-auto-rows: 1fr; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 20vw; gap: 1.5rem;
} }
.projectContainer { /* ── Card ── */
position: relative;
display: flex; .project-card {
align-items: center; border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-surface);
transition: border-color 0.25s, transform 0.25s;
} }
.displayBox { .project-card:hover {
width: 100vw; border-color: var(--color-border-hover);
height: auto; transform: translateY(-2px);
} }
a.projectContainer { /* ── Image ── */
text-decoration: none;
.project-image-link {
display: block;
overflow: hidden;
} }
.projectImage { .projectImage {
transition: width 0.5s ease; width: 100%;
height: auto;
display: block;
transition: transform 0.4s ease;
} }
.projectImage:hover { .project-card:hover .projectImage {
width: 40vw; transform: scale(1.03);
transition: width 0.5s ease; }
/* ── 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;
}
} }
+108 -41
View File
@@ -1,67 +1,134 @@
body { *, *::before, *::after {
margin: 0px; box-sizing: border-box;
background-color: #2b2c30;
color: #09ff00;
overflow-x: hidden;
cursor: default;
} }
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 { #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 { .site-name {
font: bold 2vw arial; font-size: 1.05rem;
display: inline; font-weight: 700;
margin-left: 10vw; 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; cursor: pointer;
transition: color 0.5s ease, font 0.5s ease; transition: color 0.2s;
text-decoration: none;
} }
.headerOption:hover { .nav-link:hover {
font: bold 2.3vw arial; color: var(--color-heading-secondary);
transition: color 0.5s ease, font 0.5s ease; }
/* ── 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 { #WelcomeMessage {
font: 3vw arial; font-size: clamp(1.25rem, 2.2vw, 1.75rem);
width: auto; max-width: 720px;
height: 40vw; min-height: clamp(12rem, 28vw, 20rem);
margin-top: 15vw; line-height: 1.7;
text-align: left; color: var(--color-accent);
padding-left: 10vw; font-family: var(--font-mono);
padding-right: 10vw;
} }
/* ── 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 { #DemosBox {
margin: 10vw; max-width: 1100px;
display: flex; margin: clamp(4rem, 8vw, 8rem) auto;
flex-direction: column; padding: 0 clamp(1.5rem, 5vw, 4rem);
justify-content: end;
width: 80%;
} }
#DemoHeader { #buttonWrapper {
margin: 0; display: grid;
color: rgb(212, 212, 212); grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
font-size: 4vw; gap: 0.75rem;
} }
.demoButton { .demoButton {
margin-top: 5vw; padding: 0.875rem 1.25rem;
margin-right: 2vw;
padding-top: 0.5vw;
padding-bottom: 0.5vw;
text-align: center; text-align: center;
border: none; border: 1px solid var(--color-border);
border-radius: 5vw; border-radius: 8px;
width: 25vw; font-size: 0.875rem;
height: 10vw; font-family: inherit;
font-size: 2vw; background: var(--color-surface);
background-color: rgb(104, 255, 0, 0.39); color: var(--color-text);
cursor: pointer; 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 { #MoreFiller {
cursor: default; 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;
}
} }
@@ -0,0 +1,162 @@
body {
background: #1a1a1a;
min-height: 100vh;
margin: 0;
padding: 0;
}
.memory-lane-container {
max-width: 1000px;
margin: 0 auto;
padding: 4rem 2rem;
text-align: center;
}
.memory-lane-header {
color: #f5f5f5;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 300;
letter-spacing: 0.1em;
}
.memory-lane-subtitle {
color: #a0a0a0;
font-size: 1rem;
margin-bottom: 4rem;
font-weight: 300;
}
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 3rem auto;
max-width: 900px;
padding: 0 1rem;
}
.event-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;
}
.event-card:hover {
background: #2f2f2f;
border-color: #4a4a4a;
transform: translateY(-2px);
}
.event-card.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.event-card.disabled:hover {
background: #2a2a2a;
border-color: #3a3a3a;
transform: none;
}
.event-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.9;
}
.event-title {
font-size: 1.2rem;
font-weight: 400;
color: #f5f5f5;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.event-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;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.memory-lane-header {
font-size: 2.5rem;
}
.memory-lane-subtitle {
font-size: 1.1rem;
}
.events-grid {
grid-template-columns: 1fr;
}
.event-icon {
font-size: 3rem;
}
.event-title {
font-size: 1.6rem;
}
}
+28
View File
@@ -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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1,131 @@
const ChessAPI = {
async joinGame() {
const response = await fetch('/api/chess/JoinGame');
return await response.json();
},
async createCPUGame(difficulty, color = "random") {
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
return await response.json();
},
async getGameState(gameId) {
const response = await fetch(`/api/chess/${gameId}`);
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");
return await response.json();
},
async makeMove(moveDto) {
const response = await fetch("/api/chess/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(moveDto)
});
if (!response.ok) {
let message = await response.text();
throw new Error(message || "Invalid move or not your turn.");
}
// 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 (!data.result.success) {
throw new Error(data.result.message || "Invalid move or not your turn.");
}
return data;
},
async handleMove(targetRow, targetCol) {
if (!GameState.selectedPiece) return;
const isPawn = GameState.selectedPiece.type === 0;
const reachedEnd = (Number(GameState.selectedPiece.color) === 0 && Number(targetRow) === 0) ||
(Number(GameState.selectedPiece.color) === 1 && Number(targetRow) === 0);
let choice = null;
if (isPawn && reachedEnd) {
choice = await ChessModals.promptPromotion();
if (!choice) return;
}
[targetRow, targetCol] = ChessUtils.flipCoordinates(targetRow, targetCol);
const moveDto = {
GameId: GameState.currentGameId,
PlayerId: GameState.currentPlayerId,
PieceId: GameState.selectedPiece.id,
SourceRow: GameState.selectedPiece.row,
SourceCol: GameState.selectedPiece.col,
TargetRow: targetRow,
TargetCol: targetCol,
PromotionChoice: choice
};
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
try {
const { result, state } = await this.makeMove(moveDto);
ChessBoard.renderState(state);
GameState.clearSelection();
ChessInteractions.clearHighlights();
this.alertGameStatusChange(result);
} catch (error) {
alert("❌ " + error.message);
}
},
alertGameStatusChange(moveResult) {
setTimeout(async () => {
let gameOver = false;
if (moveResult.isCheck) {
console.log("🛑 Check!");
}
if (moveResult.isCheckmate) {
alert("♟️ Checkmate!");
gameOver = true;
} 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);
}
};
console.log("ChessAPI.js loaded");
@@ -0,0 +1,275 @@
// 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();
this.placePieces(pieces);
this.setupSquareEventHandlers();
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}`);
square.innerHTML = "";
}
},
placePieces(pieces) {
pieces.forEach(piece => {
const [r, c] = ChessUtils.flipCoordinates(piece.row, piece.col);
const index = r * 8 + c;
const square = document.getElementById(`square-${index}`);
if (!square) return;
const img = document.createElement("img");
img.src = ChessUtils.getPieceImageUrl(piece);
img.alt = piece.type;
img.classList.add("chessPiece");
this.setupPieceDragEvents(img, piece);
this.setupPieceClickEvent(img, piece);
square.appendChild(img);
});
},
setupPieceDragEvents(img, piece) {
img.draggable = true;
img.ondragstart = async (e) => {
GameState.setSelectedPiece(piece);
try {
const moves = await ChessAPI.getLegalMoves(piece.id);
GameState.setLegalMoves(moves);
ChessInteractions.highlightSelected(piece.row, piece.col);
ChessInteractions.highlightLegalMoves(moves);
} catch (err) {
console.error(err);
}
e.dataTransfer.setData("text/plain", JSON.stringify({
srcRow: piece.Row,
srcCol: piece.Col
}));
};
img.ondragend = () => ChessInteractions.clearHighlights();
},
setupPieceClickEvent(img, piece) {
img.onclick = (e) => {
e.stopPropagation();
ChessInteractions.handlePieceClick(piece);
console.log("Clicked on:", piece);
};
},
setupSquareEventHandlers() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
square.classList.remove("previous-start", "previous-end");
if (!square.classList.contains("legal")) {
square.onclick = () => ChessInteractions.clearHighlights();
}
}
},
highlightPreviousMove() {
if (GameState.previousMoveStart && GameState.previousMoveEnd) {
const [startRow, startCol] = ChessUtils.flipCoordinates(...GameState.previousMoveStart);
const [endRow, endCol] = ChessUtils.flipCoordinates(...GameState.previousMoveEnd);
const startIndex = startRow * 8 + startCol;
const endIndex = endRow * 8 + endCol;
document.getElementById(`square-${startIndex}`)?.classList.add("previous-start");
document.getElementById(`square-${endIndex}`)?.classList.add("previous-end");
}
},
setupDragAndDrop() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
square.ondragover = (e) => {
e.preventDefault();
};
square.ondrop = (e) => {
e.preventDefault();
if (!GameState.selectedPiece) return;
const index = parseInt(square.id.split('-')[1], 10);
const targetRow = Math.floor(index / 8);
const targetCol = index % 8;
ChessAPI.handleMove(targetRow, targetCol);
};
}
},
renderCoordinateLabels() {
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
// If player is black, reverse the coordinates
if (!GameState.currentPlayerIsWhite) {
files.reverse();
ranks.reverse();
}
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const row = Math.floor(i / 8);
const col = i % 8;
// Add rank label (1-8) on the leftmost column
if (col === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coordinate-label row-label';
rankLabel.textContent = ranks[row];
square.appendChild(rankLabel);
}
// Add file label (a-h) on the bottom row
if (row === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coordinate-label col-label';
fileLabel.textContent = files[col];
square.appendChild(fileLabel);
}
}
}
};
console.log("ChessBoard.js loaded");
@@ -0,0 +1,59 @@
const ChessInteractions = {
async handlePieceClick(piece) {
this.clearHighlights();
GameState.setSelectedPiece(piece);
try {
const moves = await ChessAPI.getLegalMoves(piece.id);
GameState.setLegalMoves(moves);
this.highlightSelected(piece.row, piece.col);
this.highlightLegalMoves(moves);
} catch (err) {
console.error("Error in handlePieceClick:", err);
}
},
highlightSelected(row, col) {
const [r, c] = ChessUtils.flipCoordinates(row, col);
const index = r * 8 + c;
document.getElementById(`square-${index}`).classList.add("selected");
},
highlightLegalMoves(moves) {
moves.forEach(move => {
const [r, c] = ChessUtils.flipCoordinates(move.row, move.col);
const index = r * 8 + c;
const square = document.getElementById(`square-${index}`);
square.classList.add("legal");
const img = square.querySelector("img");
if (img) img.onclick = null;
let overlay = document.createElement("div");
overlay.className = "legalOverlay";
square.appendChild(overlay);
square.onclick = () => ChessAPI.handleMove(r, c);
});
},
clearHighlights() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const overlay = square.querySelector(".legalOverlay");
if (overlay) square.removeChild(overlay);
if (square.classList.contains("legal")) {
square.classList.remove("legal");
square.onclick = null;
}
square.classList.remove("selected");
}
GameState.clearSelection();
}
};
console.log("ChessInteractions.js loaded");
@@ -0,0 +1,39 @@
const ChessModals = {
promptPromotion() {
return new Promise(resolve => {
let color = 1;
if (GameState.currentPlayerIsWhite)
color = 0;
ChessUtils.updatePromotionModalImages(color);
document.getElementById("promotionModal").style.display = "block";
window.selectPromotion = (piece) => {
document.getElementById("promotionModal").style.display = "none";
resolve(piece);
};
});
},
promptDifficulty() {
return new Promise(resolve => {
document.getElementById("difficultyModal").style.display = "block";
window.selectDifficulty = (difficulty) => {
document.getElementById("difficultyModal").style.display = "none";
resolve(difficulty);
};
});
},
promptColor() {
return new Promise(resolve => {
document.getElementById("colorModal").style.display = "block";
window.selectColor = (color) => {
document.getElementById("colorModal").style.display = "none";
resolve(color);
};
});
}
};
console.log("ChessModals.js loaded");
@@ -0,0 +1,71 @@
const ChessSignalR = {
connection: null,
async setupConnection() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl("/chessHub")
.configureLogging(signalR.LogLevel.Information)
.build();
this.connection.onclose(err => {
console.error("❌ SignalR connection closed:", err?.message);
});
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 {
await this.connection.start();
console.log("✅ SignalR connected");
await this.connection.invoke("JoinWebsocketGroup", GameState.currentGameId);
} catch (err) {
console.error("❌ SignalR failed to start or join:", err);
}
},
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
if (gameId !== GameState.currentGameId) return;
// 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.renderState(state);
ChessAPI.alertGameStatusChange(moveResultDto);
},
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() {
if (this.connection) {
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
await this.connection.stop();
this.connection = null;
}
},
async stopConnection() {
if (this.connection) {
await this.connection.stop();
this.connection = null;
}
}
};
console.log("ChessSignalR.js loaded");
@@ -0,0 +1,44 @@
const ChessUtils = {
flipCoordinates(row, col) {
if (!GameState.currentPlayerIsWhite) {
return [7 - row, 7 - col];
}
return [row, col];
},
getCookie(name) {
const value = document.cookie.split('; ')
.find(row => row.startsWith(name + '='));
return value ? value.split('=')[1] : null;
},
setCookie(name, value, maxAge = 86400) {
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`;
},
getPieceImageUrl(piece) {
const basePath = "/images/Chess Images/";
const color = piece.color === 0 ? "White" : "Black";
const typeMap = {
0: "Pawn",
1: "Rook",
2: "Knight",
3: "Bishop",
4: "Queen",
5: "King"
};
return basePath + color + typeMap[piece.type] + ".svg";
},
updatePromotionModalImages(color) {
const pieceNames = ["Queen", "Rook", "Bishop", "Knight"];
const buttons = document.querySelectorAll("#promotionModal button img");
buttons.forEach((img, index) => {
img.src = `/images/Chess Images/${color === 0 ? "White" : "Black"}${pieceNames[index]}.svg`;
});
}
};
console.log("ChessUtils.js loaded");
@@ -0,0 +1,64 @@
const GameState = {
currentGameId: null,
currentPlayerId: null,
currentPlayerIsWhite: null,
selectedPiece: null,
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;
this.currentPlayerId = null;
this.currentPlayerIsWhite = null;
this.selectedPiece = null;
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) {
this.selectedPiece = piece;
},
clearSelection() {
this.selectedPiece = null;
this.legalMoves = [];
},
setLegalMoves(moves) {
this.legalMoves = moves;
},
setPreviousMove(startPos, endPos) {
this.previousMoveStart = startPos;
this.previousMoveEnd = endPos;
},
clearPreviousMove() {
this.previousMoveStart = null;
this.previousMoveEnd = null;
}
};
console.log("GameState.js loaded");
@@ -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");
@@ -0,0 +1,146 @@
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();
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
GameState.clearPreviousMove();
ChessUtils.setCookie('chessGameId', gameData.gameId);
ChessUtils.setCookie('chessPlayerId', gameData.id);
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
console.log("🆕 Game started:", gameData.gameId);
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start new game:", error);
}
}
async function startCPUGame() {
closeMenu();
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
try {
const difficulty = await ChessModals.promptDifficulty();
const color = await ChessModals.promptColor();
const gameData = await ChessAPI.createCPUGame(difficulty, color);
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
GameState.clearPreviousMove();
ChessUtils.setCookie('chessGameId', gameData.gameId);
ChessUtils.setCookie('chessPlayerId', gameData.id);
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
console.log("🆕 CPU Game started:", gameData.gameId);
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start CPU game:", error);
}
}
async function resumeSavedGame() {
const savedGameId = ChessUtils.getCookie("chessGameId");
const savedPlayerId = ChessUtils.getCookie("chessPlayerId");
const savedPlayerIsWhite = ChessUtils.getCookie("chessPlayerIsWhite");
if (savedGameId && savedPlayerId) {
try {
const gameState = await ChessAPI.getGameState(savedGameId);
console.log("🧠 Rejoining saved game...");
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
await ChessSignalR.setupConnection();
ChessBoard.renderState(gameState);
} catch (err) {
console.warn("Saved game not found or expired.", err);
}
}
}
window.startNewGame = startNewGame;
window.startCPUGame = startCPUGame;
window.addEventListener('load', resumeSavedGame);
document.addEventListener('DOMContentLoaded', () => {
ChessBoard.setupDragAndDrop();
});
console.log("chessMain.js loaded");
+16
View File
@@ -0,0 +1,16 @@
document.addEventListener('DOMContentLoaded', () => {
getIpStatus();
});
async function getIpStatus() {
try {
const res = await fetch(`/api/debug/IpCheck`);
if (!res.ok) throw new Error("API failed");
const text = await res.text();
console.log(text);
document.querySelector('#IsIpChecking').textContent = text;
} catch (err) {
console.error("Error in api:", err);
}
}
@@ -0,0 +1,7 @@
window.onload = function () {
const element = document.getElementById("WelcomeMessage");
element.style.display = "none"; // Trigger reflow
element.offsetHeight; // Force reflow
element.style.display = ""; // Restore the original display
typeText("#WelcomeMessage", "Today, October 2nd, marks the 5th anniversary of the day I was sealed for time and all eternity to my wonderful wife. She's the kindest, sweetest, funniest, most beautiful woman I've ever met. I love you Morgan 💖");
}
+1 -1
View File
@@ -3,5 +3,5 @@
element.style.display = "none"; // Trigger reflow element.style.display = "none"; // Trigger reflow
element.offsetHeight; // Force reflow element.offsetHeight; // Force reflow
element.style.display = ""; // Restore the original display element.style.display = ""; // Restore the original display
typeText("#WelcomeMessage", "Hey! You found me! Since I have your attention, why don't I tell you about myself?"); typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid...```````````````````Just...```````uhhhh...````````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?");
} }
+20 -9
View File
@@ -12,23 +12,27 @@
let intervalId; let intervalId;
let index = 0; let index = 0;
const punctuation = ['.', '!', '?']; const punctuation = ['.', '!', '?'];
const punctuationDict = {
'.': 150,
'!': 200,
'?': 200,
',': 100,
'`': 25,
};
let currentText = ""; let currentText = "";
function changeIntervalTime(element, text) { function changeIntervalTime(element, text) {
if (punctuation.includes(text.charAt(index))) {
clearInterval(intervalId); clearInterval(intervalId);
simulateTyping(element, text, 200); simulateTyping(element, text, punctuationDict[text.charAt(index)] ?? 50);
}
else {
clearInterval(intervalId);
simulateTyping(element, text);
}
} }
function simulateTyping(element, text, speed = 50) { function simulateTyping(element, text, speed = 50) {
intervalId = setInterval(() => { intervalId = setInterval(() => {
if (index < text.length) { if (index < text.length) {
if (text.charAt(index) === '`')
currentText = currentText.slice(0, -1);
else
currentText += text.charAt(index); currentText += text.charAt(index);
changeIntervalTime(element, text); changeIntervalTime(element, text);
index++; index++;
@@ -41,9 +45,9 @@ function simulateTyping(element, text, speed = 50) {
function showClickedContents(option) { function showClickedContents(option) {
if (option === 'projects') { if (option === 'projects') {
document.querySelector("#ChessProject > div.diagonal-section-left").scrollIntoView({ document.querySelector("#ProjectsBox").scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "start",
inline: "nearest" inline: "nearest"
}); });
} }
@@ -64,4 +68,11 @@ function showClickedContents(option) {
inline: "nearest" inline: "nearest"
}); });
} }
else if (option === 'blog') {
document.querySelector("#BlogBox").scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest"
});
}
} }
+759
View File
@@ -0,0 +1,759 @@
# R&D Findings: Swappable C++ Chess Engine via P/Invoke
**Team:** Architect, Backend Engineer, Performance Engineer, DevOps Engineer (orchestrated by team lead)
**Date:** 2026-05-28
**Status:** Complete — engine *infrastructure* design only; the C++ search logic is intentionally left as an empty, compilable stub for the user to implement.
---
## Executive Summary
We investigated how to host a custom chess engine written in C++ (compiled to `chess_engine.dll` for local Windows debugging and `libchess_engine.so` for the Linux server) behind a C# "middleman" wrapper that shares an interface with the existing `Stockfish.cs`, so the two are swappable by config. The key conclusion: the existing engine already uses a **string contract (FEN in → UCI move out)**, so the cleanest design mirrors it exactly — an opaque-handle `extern "C"` ABI, a `[LibraryImport]` P/Invoke wrapper, and a new `IChessEngine` interface that both `Stockfish` and the new `CustomChessEngine` implement, selected by a factory reading `appsettings`. The recommended next step is to land the **infrastructure** (interface, factory, P/Invoke wrapper, CMake project, empty C++ stub, build/packaging) and verify the round trip end-to-end with the stub returning a placeholder move — *before* any chess logic is written.
> **Naming reconciliation:** the specialists used varying names for the native lib (`ChessEngine`, `chessengine`, `chess_engine`). This document standardizes on **logical name `chess_engine`** → `chess_engine.dll` (Windows) / `libchess_engine.so` (Linux), matching the Backend and DevOps proposals. Adjust the snippets from the Architect/Performance sections accordingly.
### High-Level Architecture
```mermaid
graph TD
subgraph Managed["C# / .NET 8"]
CTRL[ChessController] --> FAC[IChessEngineFactory]
FAC -->|reads appsettings| OPT[ChessEngineOptions]
FAC -->|Stockfish| SF[Stockfish : IChessEngine]
FAC -->|Custom| CE[CustomChessEngine : IChessEngine]
CTRL --> ORCH[IComputerMoveOrchestrator]
ORCH -->|GetBestMoveAsync fen| IFACE[/IChessEngine/]
SF -.implements.-> IFACE
CE -.implements.-> IFACE
ORCH --> RULES[IChessService rules]
ORCH --> HUB[ChessHub SignalR]
end
subgraph Native["C++ shared library"]
CE -->|P/Invoke once per move| ABI["extern C ABI<br/>engine_best_move(fen, out)"]
ABI --> SEARCH["self-contained search<br/>bitboards, zero managed callbacks"]
end
SF -->|child process stdio| SFEXE[stockfish-*.exe]
```
---
## Findings by Area
### Architect: Engine Abstraction & Swap Mechanism
#### Current State
Two distinct concerns are easy to confuse:
- **`IChessService` / `ChessService`** — the *rules engine* (move legality, check/mate). NOT the thing being swapped. `JoshHeaps.Net\Services\Interfaces\IChessService.cs:5-11`, `ChessService.cs:6`.
- **`Stockfish`** — the *AI move-selection engine* (the thing being swapped). `Services\Implementations\Stockfish.cs:13`.
`Stockfish` is a concrete `sealed` class with **no interface**, carrying three mixed responsibilities:
1. **Engine lifecycle/IO** — spawns a child process, UCI handshake, `GetBestMoveAsync(fen)`. `Stockfish.cs:13-111`.
2. **Orchestration**`MakeMove(GameState, IHubContext<ChessHub>, IChessService)` gets a move, converts it, calls the rules service, broadcasts over SignalR. `Stockfish.cs:113-126`. Engine-agnostic glue.
3. **FEN/UCI translation**`StockfishHelpers.ToFen(...)` / `ToMoveDto(...)`. `Stockfish.cs:129-253`. Also engine-agnostic.
Coupling points where the concrete type leaks:
- `GameState.Computer` typed as concrete `Stockfish?` — domain model → implementation (inverted dependency). `Models\GameState.cs:46`, `using ...Implementations;` at `GameState.cs:1`.
- Controller hand-constructs the engine: `gameState.Computer = new(difficulty);` `Controllers\ChessController.cs:45`. No DI, no abstraction.
- Orchestration via concrete instance: `gameState.Computer.MakeMove(...)` at `ChessController.cs:60` and `:192`.
- Disposal via concrete type: `await game.Computer.DisposeAsync();` `ChessController.cs:248`.
- `Stockfish` is **not** registered in DI; `Program.cs:21-23` registers other services but the engine is `new`-ed inline per game.
```mermaid
classDiagram
class GameState {
+Stockfish? Computer
+bool IsVsComputer
}
class ChessController
class Stockfish {
+GetBestMoveAsync(fen) Task~string~
+MakeMove(state, hub, chessService) Task
+DisposeAsync() ValueTask
}
class StockfishHelpers {
+ToFen(GameState)$ string
+ToMoveDto(uci, state, playerId)$ MoveDto
}
class IChessService { <<interface>> }
class ChessService
IChessService <|.. ChessService
ChessController --> GameState : new()s Stockfish into
GameState *-- Stockfish : owns concrete
ChessController ..> Stockfish : MakeMove() / DisposeAsync()
Stockfish ..> StockfishHelpers
Stockfish ..> IChessService
Stockfish ..> ChessHub : broadcasts
note for GameState "Models depends on\nServices.Implementations — inverted"
note for Stockfish "Orchestration lives inside engine —\nwould duplicate across 2 engines"
```
#### Findings
1. **No interface → cannot swap.** `Stockfish` is `sealed`, concrete-only (`Stockfish.cs:13`). Adding `CustomChessEngine` today means touching `GameState`, the controller, and disposal. *Impact:* an `IChessEngine` abstraction is the core requirement.
2. **Domain model depends on a concrete service.** `GameState.cs:46` + `using ...Implementations` at `GameState.cs:1`. *Impact:* must become `IChessEngine?` (or leave the model entirely).
3. **Engine hand-constructed in controller, no DI.** `ChessController.cs:45`. *Impact:* engine selection can't be config-driven; this is the seam for a factory.
4. **Orchestration lives in the engine.** `Stockfish.MakeMove(...)` (`Stockfish.cs:113-126`) is not Stockfish-specific. *Impact:* copying it into `CustomChessEngine` duplicates rules-call + broadcast wiring; lift it to an orchestrator.
5. **FEN/UCI translation is engine-agnostic** (`Stockfish.cs:169`, `:131`). *Impact:* keep shared (rename to `ChessEngineHelpers`), don't duplicate.
6. **Engine lifecycle is game-scoped, not DI-scoped.** Created in `CreateGame` (`ChessController.cs:45`), disposed in the game-removal timer (`ChessController.cs:247-248`); the constructor spawns a process and blocks on UCI handshake (`Stockfish.cs:54,72,77`). *Impact:* a plain singleton won't fit — use a **per-game factory** producing disposable instances.
7. **`MakeMove` is fire-and-forget returning non-generic `Task`** (`Stockfish.cs:113`). *Impact:* when lifted, the orchestrator should return the `(MoveDto, MoveResultDto)` it produced (per CodingStyle "Void Avoidance").
#### Suggested Approach
Three roles: `IChessEngine` (swappable contract), `IChessEngineFactory` (per-game creation from config), `IComputerMoveOrchestrator` (lifted glue).
```csharp
// Services/Interfaces/IChessEngine.cs
public interface IChessEngine : IAsyncDisposable
{
int Skill { get; }
// UCI long-algebraic, e.g. "e2e4", "e7e8q"
Task<string> GetBestMoveAsync(string fen, CancellationToken cancellationToken = default);
}
```
```csharp
// Services/Implementations/Stockfish.cs (minimal change)
public sealed class Stockfish : IChessEngine // was: IAsyncDisposable
{
public int Skill => _skill;
public Task<string> GetBestMoveAsync(string fen, CancellationToken ct = default) { /* existing body */ }
public ValueTask DisposeAsync() { /* unchanged */ }
// DELETE MakeMove(...) -> moves to IComputerMoveOrchestrator
}
// StockfishHelpers -> rename to ChessEngineHelpers in Services/Implementations/ChessEngineHelpers.cs (body unchanged)
```
```csharp
// Services/Interfaces/IChessEngineFactory.cs
public interface IChessEngineFactory { IChessEngine Create(int skill); }
// Services/Implementations/ChessEngineFactory.cs
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
{
private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => _kind switch
{
ChessEngineKind.Custom => new CustomChessEngine(skill),
ChessEngineKind.Stockfish => new Stockfish(skill),
_ => throw new InvalidOperationException($"Unknown engine '{_kind}'.")
};
}
public enum ChessEngineKind { Stockfish, Custom }
public sealed class ChessEngineOptions
{
public const string SectionName = "ChessEngine";
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
}
```
```csharp
// Program.cs (near :21-23)
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
```
```jsonc
// appsettings.json — flip to "Custom" to swap (override per-env in appsettings.Development.json)
{ "ChessEngine": { "Engine": "Stockfish" } }
```
```csharp
// Services/Implementations/ComputerMoveOrchestrator.cs (lifted from Stockfish.MakeMove, engine-agnostic)
public interface IComputerMoveOrchestrator
{
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default);
}
public sealed class ComputerMoveOrchestrator(IHubContext<ChessHub> chessHub, IChessService chessService) : IComputerMoveOrchestrator
{
public async Task<(MoveDto, MoveResultDto)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default)
{
var uci = await engine.GetBestMoveAsync(state.ToFen(), ct);
var move = uci.ToMoveDto(state, state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId);
var result = chessService.MakeMove(state, move);
await chessHub.Clients.Group(state.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, ct);
return (move, result);
}
}
```
**`GameState.Computer` recommendation:** keep the reference on `GameState` but retype to `IChessEngine?` (fixes Finding 2) — do *not* go full service-injection. Rationale: the engine is a stateful, per-game, disposable resource whose lifetime is managed by `ScheduleRemoveGame` (`ChessController.cs:247-248`); live games live in the controller's static `ConcurrentDictionary` (`ChessController.cs:20`), not DI. Only the engine's *construction* (factory) and *orchestration* (orchestrator) move out.
```csharp
// ChessController changes (inject factory + orchestrator into the primary ctor at :12-15)
gameState.Computer = engineFactory.Create(difficulty); // was :45 new(difficulty)
await orchestrator.PlayAsync(gameState, gameState.Computer); // was :60
if (gameState.IsVsComputer && gameState.Computer is not null) // was :192
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
await game.Computer.DisposeAsync(); // :248 unchanged (IChessEngine : IAsyncDisposable)
```
```mermaid
classDiagram
class IChessEngine {
<<interface>>
+int Skill
+GetBestMoveAsync(fen, ct) Task~string~
+DisposeAsync() ValueTask
}
class Stockfish
class CustomChessEngine { -P/Invoke chess_engine (.dll/.so) }
class IChessEngineFactory { <<interface>> +Create(skill) IChessEngine }
class ChessEngineFactory
class ChessEngineOptions { +ChessEngineKind Engine }
class IComputerMoveOrchestrator { <<interface>> +PlayAsync(state, engine, ct) }
class ComputerMoveOrchestrator
class GameState { +IChessEngine? Computer }
class ChessController
class ChessEngineHelpers
IChessEngine <|.. Stockfish
IChessEngine <|.. CustomChessEngine
IChessEngineFactory <|.. ChessEngineFactory
IComputerMoveOrchestrator <|.. ComputerMoveOrchestrator
ChessEngineFactory ..> ChessEngineOptions : reads config
ChessEngineFactory ..> Stockfish : creates
ChessEngineFactory ..> CustomChessEngine : creates
ChessController ..> IChessEngineFactory
ChessController ..> IComputerMoveOrchestrator
GameState o-- IChessEngine : holds abstraction
ComputerMoveOrchestrator ..> IChessEngine
ComputerMoveOrchestrator ..> ChessEngineHelpers
ComputerMoveOrchestrator ..> IChessService
note for GameState "Now depends on Interfaces (DIP fixed)"
note for CustomChessEngine "C++ body empty; P/Invoke contract only"
```
#### Open Questions
1. Does the custom engine speak FEN-in / UCI-out? (Assumed yes — confirm before locking the interface.)
2. Per-game instance vs shared singleton for the in-process native engine (a `.dll`/`.so` may be cheap enough to share, unlike a Stockfish process).
3. Skill/options surface — `int skill` vs an `EngineOptions` object if tunables diverge from Stockfish's `(skill, hash)` (`Stockfish.cs:20`).
4. Cancellation/timeout — interface has a `CancellationToken` but nothing wires it today (`BackgroundTaskQueue.cs:11` fire-and-forgets).
5. Concurrency — `GetBestMoveAsync` is not re-entrant per instance (single stdout `Channel`, `Stockfish.cs:17`); per-game instances make this moot today.
---
### Backend Engineer: Native Interop & I/O Contract
#### Current State
The load-bearing method is `GetBestMoveAsync(string fen)` at `Stockfish.cs:80-95`: writes `position fen <fen>` + `go depth N` to stdin (`:82-83`), reads stdout until a line starts with `bestmove` (`:88`), returns the second token — a raw UCI string like `e2e4` / `e7e8q` (`:90`). Input is produced by `ToFen()` (`Stockfish.cs:169-214`); output is consumed by `ToMoveDto()` (`Stockfish.cs:131-162`, parses chars `uci[0..4]` into `MoveDto`, `MoveDto.cs:3-20`). Consumer: `ChessController.cs:191-192` queues `gameState.Computer.MakeMove(...)`.
**There is no native interop today.** A repo-wide grep for `DllImport|LibraryImport|NativeLibrary|Marshal|extern` returns only an unrelated hit in vendored `jquery.js`. Project targets `net8.0`, `Nullable=enable`, `ImplicitUsings=enable` (`JoshHeaps.Net.csproj:3-7`).
```mermaid
sequenceDiagram
participant C as ChessController
participant SF as Stockfish (C#)
participant P as stockfish.exe (subprocess)
C->>SF: MakeMove(state) / GetBestMoveAsync(fen)
Note over SF: state.ToFen() builds FEN
SF->>P: stdin "position fen <FEN>"
SF->>P: stdin "go depth N"
P-->>SF: stdout "info ..."
P-->>SF: stdout "bestmove e2e4"
Note over SF: Split(' ')[1] => "e2e4"
SF-->>C: "e2e4" (UCI)
Note over C: uci.ToMoveDto(state) => MoveDto
```
#### Findings
1. **The boundary is already a pure string pair** (`Stockfish.cs:80,90`). *Impact:* the native ABI should mirror it exactly — `const char* fen` in, `char*` UCI out. No struct marshalling needed.
2. **No `IChessEngine` abstraction; consumers bind the concrete type** (`GameState.cs:46`, `ChessController.cs:191-192`). *Impact:* the middleman implements the Architect's interface; the `GameState` retype is a cross-cutting dependency.
3. **`MakeMove` mixes engine + SignalR/board concerns** (`Stockfish.cs:113-126`). *Impact:* keep it shared (orchestrator), not per-engine. The native-specific surface of `CustomChessEngine` is only `GetBestMoveAsync`.
4. **The UCI string is the contract anchor.** As long as the native engine emits a 4-or-5-char UCI move, the whole downstream pipeline (`MoveDto``IChessService.MakeMove` → SignalR) is unchanged (`Stockfish.cs:131,169`).
5. **Resource-shipping pattern is established** (`csproj:14-18` copies `Resources/**`). *Impact:* the `.dll`/`.so` ship the same way.
#### Suggested Approach
**Contract: strings (FEN in / UCI out), not a binary struct.** It is byte-identical to today's contract (so `ToFen`/`ToMoveDto` are untouched); FEN/UCI are stable ASCII (no layout/packing/endianness/enum-width to keep in sync); the user only writes string parsing in C++; per-move data is tiny. **Buffer-ownership rule:** the C# caller owns the output buffer; the engine only writes into it and never allocates returned strings — sidesteps cross-allocator free bugs.
```c
// native/chess_engine/include/chess_engine.h
#ifndef CHESS_ENGINE_H
#define CHESS_ENGINE_H
#include <stddef.h>
#if defined(_WIN32)
#ifdef CHESS_ENGINE_BUILD
#define CHESS_API __declspec(dllexport)
#else
#define CHESS_API __declspec(dllimport)
#endif
#define CHESS_CALL __cdecl
#else
#define CHESS_API __attribute__((visibility("default")))
#define CHESS_CALL
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct ChessEngine* EngineHandle; // opaque; host never dereferences
enum {
CHESS_OK = 0, CHESS_ERR_NULL_HANDLE = -1, CHESS_ERR_BAD_FEN = -2,
CHESS_ERR_NO_MOVE = -3, CHESS_ERR_BUFFER = -4, CHESS_ERR_INTERNAL = -5
};
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); // options e.g. "skill=20;hash=256" or NULL
CHESS_API int CHESS_CALL engine_set_option(EngineHandle, const char* name, const char* value);
CHESS_API int CHESS_CALL engine_best_move(EngineHandle, const char* fen, char* out_buf, int out_len); // writes "e2e4\0"
CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
CHESS_API void CHESS_CALL engine_destroy(EngineHandle); // safe with NULL
#ifdef __cplusplus
}
#endif
#endif
```
```cpp
// native/chess_engine/src/chess_engine.cpp — EMPTY stub; compiles, returns placeholder
#define CHESS_ENGINE_BUILD
#include "chess_engine.h"
#include <cstring>
#include <string>
struct ChessEngine { std::string options; }; // put TT, tables, etc. here later
static int copy_out(const char* src, char* out, int cap) {
if (!out || cap <= 0) return CHESS_ERR_BUFFER;
const size_t need = std::strlen(src) + 1;
if (need > (size_t)cap) return CHESS_ERR_BUFFER;
std::memcpy(out, src, need);
return CHESS_OK;
}
extern "C" {
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
auto* e = new (std::nothrow) ChessEngine();
if (e && options) e->options = options;
return e;
}
CHESS_API int CHESS_CALL engine_set_option(EngineHandle e, const char*, const char*) {
return e ? CHESS_OK : CHESS_ERR_NULL_HANDLE;
}
CHESS_API int CHESS_CALL engine_best_move(EngineHandle e, const char* fen, char* out, int cap) {
if (!e) return CHESS_ERR_NULL_HANDLE;
if (!fen || !*fen) return CHESS_ERR_BAD_FEN;
// TODO: parse fen, search, produce a real UCI move.
return copy_out("e2e4", out, cap); // placeholder
}
CHESS_API int CHESS_CALL engine_version(char* out, int cap) { return copy_out("custom-engine 0.0.1-stub", out, cap); }
CHESS_API void CHESS_CALL engine_destroy(EngineHandle e) { delete e; }
}
```
**ABI notes:** `extern "C"` kills name mangling; `CHESS_API` = `__declspec(dllexport)` (MSVC, when `CHESS_ENGINE_BUILD` defined) or `__attribute__((visibility("default")))` (GCC/Clang, pair with `-fvisibility=hidden`); `CHESS_CALL` pins `__cdecl` on Windows, empty (SysV default) on Linux.
```csharp
// Services/Implementations/CustomChessEngine.cs
public sealed class CustomChessEngine : IChessEngine // IAsyncDisposable via IChessEngine
{
private readonly EngineSafeHandle _handle;
public int Skill { get; }
public CustomChessEngine(int skill = 20)
{
Skill = skill;
var raw = NativeMethods.engine_create($"skill={skill}");
if (raw == IntPtr.Zero) throw new InvalidOperationException("engine_create returned null.");
_handle = new EngineSafeHandle(raw);
}
public Task<string> GetBestMoveAsync(string fen, CancellationToken ct = default)
=> Task.Run(() => BestMove(fen), ct); // native call is sync + CPU-bound; offload off request thread
private string BestMove(string fen)
{
Span<byte> outBuf = stackalloc byte[16]; // UCI <= 5 chars + NUL
bool added = false;
try
{
_handle.DangerousAddRef(ref added);
int rc;
unsafe { fixed (byte* p = outBuf) rc = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, p, outBuf.Length); }
ThrowIfError(rc);
int nul = outBuf.IndexOf((byte)0);
return System.Text.Encoding.ASCII.GetString(outBuf[..(nul < 0 ? outBuf.Length : nul)]);
}
finally { if (added) _handle.DangerousRelease(); }
}
private static void ThrowIfError(int rc) { if (rc != 0) throw rc switch {
-2 => new ArgumentException("CHESS_ERR_BAD_FEN"),
-3 => new InvalidOperationException("No move (mate/stalemate)"),
-4 => new InvalidOperationException("Output buffer too small"),
_ => new InvalidOperationException($"Native engine error {rc}") }; }
public ValueTask DisposeAsync() { _handle.Dispose(); return ValueTask.CompletedTask; }
private sealed class EngineSafeHandle : SafeHandle
{
public EngineSafeHandle(IntPtr h) : base(IntPtr.Zero, true) => SetHandle(h);
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle() { NativeMethods.engine_destroy(handle); return true; }
}
private static partial class NativeMethods
{
private const string Lib = "chess_engine"; // -> chess_engine.dll / libchess_engine.so
static NativeMethods() => NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
private static IntPtr Resolve(string name, Assembly asm, DllImportSearchPath? path)
{
if (name != Lib) return IntPtr.Zero;
string file = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "chess_engine.dll" : "libchess_engine.so";
string probe = Path.Combine(AppContext.BaseDirectory, "Resources", file);
return File.Exists(probe) && NativeLibrary.TryLoad(probe, out var h) ? h : NativeLibrary.Load(name, asm, path);
}
[LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial IntPtr engine_create(string? options);
[LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuf, int outLen);
[LibraryImport(Lib)]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void engine_destroy(IntPtr engine);
}
}
```
**Why these C# choices:** `[LibraryImport]` (source-generated, AOT/trim-safe, no runtime IL stub, compile-time diagnostics) over `[DllImport]`; `StringMarshalling.Utf8` matches `const char*`; a `DllImportResolver` probes `Resources/` first then falls back to default search; `SafeHandle` guarantees `engine_destroy` runs exactly once; return codes map to typed exceptions; `Task.Run` adapts the sync native call to the async interface (for a real long search, prefer one dedicated long-running thread per instance over thread-pool churn).
```mermaid
sequenceDiagram
participant C as ChessController
participant CE as CustomChessEngine (C#)
participant TP as Task.Run (threadpool)
participant N as chess_engine.dll/.so
C->>CE: GetBestMoveAsync(state.ToFen())
CE->>TP: offload sync native call
Note over TP: stackalloc byte[16] out_buf (host-owned)
TP->>N: engine_best_move(handle, fen, out_buf, 16)
Note over N: parse FEN, search, write "e2e4\0"
N-->>TP: CHESS_OK; out_buf filled
TP-->>CE: "e2e4"
CE-->>C: "e2e4" (same UCI as Stockfish)
Note over C: uci.ToMoveDto(state) => MoveDto (unchanged)
```
#### Open Questions
1. Should `MakeMove`/orchestration be on the interface or shared via the orchestrator? (Recommend orchestrator — aligns with Architect.)
2. Who owns the `GameState.Computer` retype (Architect vs Backend)? Cross-cutting.
3. Build/packaging: CMake + MSBuild copy vs commit binaries (see DevOps).
4. Search timeout/cancellation: add `engine_stop(handle)` + token-aware wrapper, or fixed-depth like Stockfish's `go depth N` (`Stockfish.cs:83`)?
5. `options` string format (`"key=value;..."`) vs per-option `engine_set_option`?
6. Concurrency model — one move per instance at a time (current queue suggests yes)?
---
### Performance Engineer: Interop Boundary & Hot-Path Strategy
#### Current State
Two distinct things are being conflated:
1. **The Stockfish path is already fast and well-structured** — one warm process (`Stockfish.cs:40-54`), two text commands per move, block on stdout (`:82-93`). The cost is the *search* (`go depth {_skill}`, `:83`), not the pipe. **Stockfish is not slow.** It is also already a self-contained native search — the model to preserve.
2. **The C# `ChessService` is the genuinely slow thing** and the real motivation:
- **Object-graph board:** `GameState.Board` is `ChessPiece?[,]` (`GameState.cs:11`) of heap `ChessPiece` objects each with a `string Id` (`ChessPiece.cs:5`) — pointer chase / cache miss per square touch.
- **Allocation per pseudo-move:** legality calls `CloneGameState` (`ChessService.cs:319,453-483`) allocating a new `GameState`, `ChessPiece[8,8]`, `List<string>`, and a `ChessPiece` per piece — *every candidate move*.
- **LINQ in the inner loop:** `IsSquareAttacked` does `.Where(...).ToList()` + regenerates enemy moves (`ChessService.cs:396-407`); lookups by string id via `FirstOrDefault` (`:110,126`).
- Fine for validating one human move; orders of magnitude away from a search loop. Correct thing to move to C++.
```mermaid
flowchart TD
A["ChessController.MakeMove :192"] --> B["queue.Queue (IBackgroundTaskQueue)"]
B --> C["Stockfish.MakeMove :113"]
C --> D["state.ToFen() ~70B :169"]
D --> E["stdin 'position fen' + 'go depth N' :82-83"]
E --> F["stdin pipe -> stockfish.exe"]
F --> G["NATIVE SEARCH (seconds)<br/>millions of nodes, zero managed calls"]
G --> H["stdout 'bestmove e2e4' :86-92"]
H --> I["parse ~5B UCI -> MoveDto :117,136"]
style G fill:#2d6a2d,color:#fff
style F fill:#7a5c00,color:#fff
style H fill:#7a5c00,color:#fff
```
#### Findings
1. **The move boundary is provably not the hot path.** Per move: ~70-byte FEN in, ~5-byte UCI out — sub-microsecond marshalling vs a multi-second search; the boundary is ~6+ orders of magnitude cheaper than the work it gates. The string contract is correct and will never bottleneck. Production already proves it via a *heavier* transport (OS pipes).
2. **Forbidden anti-pattern: a chatty per-node boundary.** A `LibraryImport` P/Invoke transition is ~12 ns, but a search visits millions of nodes/sec. A managed callback per node (move-gen/eval) adds a GC-tracked frame, write-barrier exposure, and loss of native inlining on the hottest loop — defeating the whole point. **Rule: the native search owns move-gen, make/unmake, and eval; zero managed callbacks below the once-per-move boundary.**
3. **Board representation is native-internal, not a marshalling concern.** Use **bitboards** inside C++ (~12 `uint64_t` + occupancy/flags); make/unmake and attacks become `&`/`|`/shifts/`popcnt`/`tzcnt` instead of pointer-chasing + LINQ (`ChessService.cs:392-410`). None of it crosses the boundary — C# keeps its `GameState` graph for rendering/human-move validation; the engine rebuilds bitboards from the FEN.
4. **Threading/async.** The search is CPU-bound/synchronous; the controller already enqueues on a background queue (`ChessController.cs:192`) so the SignalR thread isn't blocked. Wrap the blocking P/Invoke in `Task.Run`; results push back over `IHubContext<ChessHub>` as today (`Stockfish.cs:125`). **Parallel search (Lazy SMP) stays 100% native.** **Cancellation = one atomic flag:** `engine_stop()` sets `std::atomic<bool>` polled between nodes — crosses the boundary once on cancel, never per node.
| Operation | Approx. cost | Frequency |
|---|---|---|
| P/Invoke transition (blittable, `LibraryImport`) | ~12 ns | once per move |
| Marshal ~70B FEN in + ~5B UCI out | < 1 µs | once per move |
| `ToFen()` string build (`Stockfish.cs:169`) | low µs | once per move |
| **Native search (`go depth N`)** | **~0.1several s** | **once per move** |
| Hypothetical managed eval callback **per node** | ~tens of ns × millions/sec | ❌ never — forbidden |
#### Suggested Approach
Cross the boundary **once per move**; keep the loop fully native.
```cpp
// RECOMMENDED: self-contained native search
extern "C" int engine_best_move(ChessEngine* e, const char* fen, char* out, int cap) {
Position pos = parse_fen(fen); // build bitboards ONCE
g_stop.store(false);
Move best = search(pos, e->depth); // millions of nodes, NO callbacks out
return write_uci(best, out, cap); // ~5 bytes back
}
```
```cpp
// FORBIDDEN: chatty boundary — do NOT do this
int search(Position& pos, int depth) {
for (Move m : managed_generate_moves(pos)) // P/Invoke OUT per node
eval += managed_eval_callback(pos); // managed frame per node — death
}
```
**Optional blittable-struct contract — recommend DEFER.** If profiling ever showed FEN parse dominating (it won't at one call/move), you *could* pass a `[StructLayout(LayoutKind.Sequential)]` `NativePosition` (12 bitboards + flags) by `in`/`ref` (fully blittable, no marshalling). But it adds a second board-layout source-of-truth and couples C# to the engine's internals. **Ship the FEN/UCI string contract; don't build the struct path until a profiler proves it's needed.**
```mermaid
flowchart TD
A["ChessController.MakeMove :192"] --> B["queue.Queue -> Task.Run (SignalR thread freed)"]
B --> C["state.ToFen() ~70B"]
C --> D{{"engine_best_move(handle, fen, buf)<br/>ONE P/Invoke crossing (~1-2ns + <1us marshal)"}}
D --> E["NATIVE: parse_fen -> bitboards (x12 ulong)"]
E --> F["NATIVE SEARCH LOOP<br/>make/unmake on bitboards, native eval<br/>Lazy SMP threads, poll atomic g_stop<br/>ZERO managed callbacks"]
F --> G{{"return ~5B UCI move<br/>ONE crossing back"}}
G --> H["parse UCI -> MoveDto -> SignalR (Stockfish.cs:117-125)"]
I["engine_stop(handle)"] -. "once on cancel, NOT per node" .-> F
style D fill:#1f4e79,color:#fff
style G fill:#1f4e79,color:#fff
style E fill:#2d6a2d,color:#fff
style F fill:#2d6a2d,color:#fff
```
**Build flags (coordinate with DevOps):** MSVC `/O2 /GL` + `/LTCG`, `/arch:AVX2` (matches the shipped AVX2 Stockfish, `Stockfish.cs:25`); GCC/Clang `-O3 -flto` with a baseline `-march` the server supports (the Linux Stockfish targets `sse41-popcnt`, `Stockfish.cs:28`) — avoid `-march=native` on a build host differing from the server.
#### Open Questions
1. **Deploy CPU baseline** — server's CPU floor dictates safe `-march`/`/arch` and whether `popcnt`/AVX2 bitboard intrinsics are guaranteed (Stockfish picks `avx2` Win / `sse41-popcnt` Linux, `Stockfish.cs:24-28`).
2. Will the native engine fully replace or coexist with Stockfish? (Recommend a shared `IChessEngine`.)
3. Engine lifetime/concurrency — one handle per game (like Stockfish today) vs reused across games (needs re-entrancy)?
4. Search termination — fixed depth, nodes, or wall-clock (time-based makes `engine_stop` most useful)?
5. Move-legality ownership — human moves validated by `ChessService.MakeMove` (`ChessController.cs:179`); two move generators risk divergence.
---
### DevOps Engineer: Cross-Platform Native Build & Packaging
#### Current State
- **Packaging:** `JoshHeaps.Net.csproj:14-18` copies the whole Resources folder as `Content` with `CopyToOutputDirectory=PreserveNewest` → lands in `bin\<cfg>\net8.0\Resources\` and `publish\Resources\`. Binaries are **committed** (`Resources\stockfish-windows-x86-64-avx2.exe`, `Resources\stockfish-ubuntu-x86-64-sse41-popcnt`).
- **Runtime load:** `Stockfish.cs:23` `AppContext.BaseDirectory`; `:24-28` OS filename switch; `:30` `Path.Combine(baseDir,"Resources",fileName)`; `:32-36` existence check. Stockfish is a **child process** (`:40-54`), so there is no native-library load path today.
- **CI/deploy is Linux-only:** `.github\workflows\deploy.yml:17` `runs-on: ubuntu-latest`, `dotnet publish -c Release` (`:29`), `rsync -az --delete publish/` to the server (`:55-58`), systemd restart (`:60-64`). PR build also `ubuntu-latest` (`dotnet.yml:13`). **A Windows `.dll` can never be produced on the runner — it must be committed.**
```mermaid
flowchart TD
A["Resources/stockfish-windows-*.exe (committed)"] --> C
B["Resources/stockfish-ubuntu-* (committed)"] --> C
C["csproj Content Resources/** PreserveNewest (csproj:14-18)"]
C --> D["dotnet publish (ubuntu-latest) deploy.yml:29"]
D --> E["publish/Resources/*"]
E --> F["rsync to Linux server deploy.yml:55-58"]
F --> G["Runtime Stockfish.cs:23,30 BaseDirectory + Resources/fileName"]
G --> H["Process.Start(exePath) — child process, not P/Invoke :40-54"]
```
#### Findings
1. **The existing Content glob already covers new Resources files** (`csproj:14-18`) — dropping the native libs in `Resources/` flows them to output/publish with zero csproj change required (explicit items optional for clarity).
2. **Build host is Linux-only** (`deploy.yml:17`, `dotnet.yml:13`) — the Windows `.dll` MUST be committed; an MSBuild→CMake target only helps on a developer's Windows box.
3. **Stockfish uses a child process, not P/Invoke** (`Stockfish.cs:40-54`) — no existing `DllImport`/`NativeLibrary` precedent; the resolver story is net-new.
4. **Resources path is hardcoded `Path.Combine(baseDir,"Resources",...)`** (`Stockfish.cs:30`), but P/Invoke's default search does NOT look in a `Resources` subfolder. *Impact:* register a `DllImportResolver` pointing at `Resources/` **or** place the lib at the output root. Biggest divergence from the Stockfish pattern.
5. **No CMake/C++ scaffolding exists** — greenfield; recommend `native/chess_engine/` at repo root, outside the csproj compile globs.
6. **glibc/libstdc++ ABI risk** — the committed `.so` is built on a dev/CI machine but runs on the rsync'd server (`deploy.yml:55`); a newer build-host libstdc++/glibc → runtime load failure. Build against a server-matching baseline or static-link libstdc++.
#### Suggested Approach
**Recommendation: build natively per-platform and commit both artifacts into `Resources/`, mirroring Stockfish.** The repo already commits platform binaries, the build host is Linux-only, and committing keeps deploy a pure `dotnet publish`. An optional opt-in MSBuild target can rebuild the matching-platform artifact locally, but must never be the deploy's source of truth.
```cmake
# native/chess_engine/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(chess_engine LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(chess_engine SHARED src/chess_engine.cpp)
target_include_directories(chess_engine PUBLIC include)
target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD)
# Windows -> chess_engine.dll ; Linux -> libchess_engine.so
set_target_properties(chess_engine PROPERTIES OUTPUT_NAME chess_engine POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
if (MSVC)
target_compile_options(chess_engine PRIVATE
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
target_link_options(chess_engine PRIVATE $<$<CONFIG:Release>:/LTCG> $<$<CONFIG:Debug>:/DEBUG>)
else()
# Portable server baseline; do NOT use -march=native (build host may differ -> SIGILL).
target_compile_options(chess_engine PRIVATE
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2> # ~SSE4.2; confirm server floor
$<$<CONFIG:Debug>:-O0 -g>)
endif()
```
The export macro (`CHESS_API`, defined under `CHESS_ENGINE_BUILD`) and `extern "C"` live in the Backend Engineer's `chess_engine.h`.
```bash
# Windows (Developer PowerShell, MSVC)
cmake -S native/chess_engine -B native/chess_engine/build -A x64
cmake --build native/chess_engine/build --config Release # -> build/Release/chess_engine.dll (+ .pdb)
# Linux (ideally in an ubuntu:22.04 container matching the server)
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build # -> build/libchess_engine.so
```
Copy each artifact into `JoshHeaps.Net/Resources/` and commit — same lifecycle as the Stockfish binaries.
**Optional local-only CMake build target** (gated `BuildNativeEngine=true`, off by default, OS-conditioned, never gates deploy):
```xml
<Target Name="BuildNativeWindows" BeforeTargets="BeforeBuild"
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'=='Windows_NT'">
<Exec Command="cmake -S native\chess_engine -B native\chess_engine\build -A x64" />
<Exec Command="cmake --build native\chess_engine\build --config $(Configuration)" />
<Copy SourceFiles="native\chess_engine\build\$(Configuration)\chess_engine.dll" DestinationFolder="Resources\" SkipUnchangedFiles="true" />
</Target>
<Target Name="BuildNativeLinux" BeforeTargets="BeforeBuild"
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'!='Windows_NT'">
<Exec Command="cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release" />
<Exec Command="cmake --build native/chess_engine/build" />
<Copy SourceFiles="native/chess_engine/build/libchess_engine.so" DestinationFolder="Resources/" SkipUnchangedFiles="true" />
</Target>
```
**Runtime load — recommendation: `DllImportResolver` pointing at `Resources/`** (co-locates with Stockfish, survives single-file publish). This matches the Backend Engineer's `NativeMethods` resolver. Keep the native libs **only** in `Resources/` (rely on the existing `Content` glob) — do not also `<Link>` them to the output root, to avoid two copies that drift.
**Local debugging (mixed-mode):** CMake `Debug` emits `/Zi` + `/DEBUG``chess_engine.pdb`; ship it next to the `.dll` for local debug builds only (Debug-only `None` item, never committed/deployed). In Visual Studio enable **Project Properties → Debug → Enable native code debugging** to step from C# P/Invoke into C++.
**Linux deployment:** `libchess_engine.so` ships via `Resources/**``publish/` → rsync automatically. It's `dlopen`'d (no `chmod +x` needed) but must be world-readable. It links `libstdc++`/`glibc` — build against a baseline ≤ the server's, or **static-link** (`-static-libstdc++ -static-libgcc`) to remove the version coupling (safest for a committed binary).
```mermaid
flowchart TD
subgraph Build["Build (per-platform)"]
W["Windows dev: cmake --build Release<br/>=> chess_engine.dll (+pdb in Debug)"]
L["Linux (ubuntu:22.04 container): cmake --build Release<br/>=> libchess_engine.so (-static-libstdc++)"]
end
W --> R["Commit into JoshHeaps.Net/Resources/ (as stockfish-* today)"]
L --> R
R --> CSP["csproj Content Resources/** (csproj:14-18) -> output/Resources/"]
CSP --> PUB["dotnet publish (ubuntu-latest) deploy.yml:29 — pure, no toolchain"]
PUB --> RS["rsync publish/ to server deploy.yml:55-58"]
RS --> RES["DllImportResolver: BaseDirectory + Resources/ + OS filename switch (mirrors Stockfish.cs:23-30)"]
RES --> PI["P/Invoke [LibraryImport(chess_engine)] -> NativeLibrary.Load"]
style W fill:#1d4ed8,color:#fff
style L fill:#15803d,color:#fff
style PI fill:#92400e,color:#fff
```
#### Open Questions
1. **Server glibc/libstdc++ version unknown** — need `ldd --version` + `strings libstdc++.so.6 | grep GLIBCXX`; otherwise build in `ubuntu:22.04` or static-link libstdc++.
2. **Commit binaries vs build `.so` in CI?** Recommend commit both (consistent, pure deploy); alternative is a Linux native-build step in `deploy.yml` for a reproducible/ABI-correct `.so`.
3. **Target CPU baseline** — assumed `-march=x86-64-v2` to match `sse41-popcnt`; confirm the server floor; `-march=native` unsafe for committed/CI binaries.
4. **`IChessEngine` doesn't exist yet** — prerequisite from the Architect (out of scope for build, flagged).
5. **`.pdb` policy** — local-only Debug symbols recommended; confirm.
---
## Cross-Cutting Concerns
```mermaid
graph TD
subgraph Arch["Architect: abstraction"]
IFACE[/IChessEngine/] --> FAC[Factory + appsettings]
end
subgraph Backend["Backend: contract"]
ABI["extern C ABI<br/>FEN -> UCI"]
end
subgraph Perf["Performance"]
ONCE["once-per-move boundary<br/>native search"]
end
subgraph DevOps["DevOps"]
PKG["commit .dll/.so in Resources<br/>DllImportResolver"]
end
IFACE -.->|"GetBestMoveAsync(fen) shape<br/>must match ABI"| ABI
ABI -.->|"string contract = thin boundary"| ONCE
ONCE -.->|"build flags -O3/AVX2/LTO"| PKG
PKG -.->|"resolver finds lib for CustomChessEngine"| IFACE
```
1. **The string contract ties all four areas together.** `IChessEngine.GetBestMoveAsync(string fen) → string` (Architect) is the exact shape of `engine_best_move(const char* fen, char* out)` (Backend), which is what makes the boundary thin (Performance) and keeps `ToFen`/`ToMoveDto` untouched. If anyone changes to a struct contract, all four must change. Evidence: `Stockfish.cs:80,90,131,169`.
2. **`GameState.Computer` retype is owned by the Architect but unblocks Backend.** `GameState.cs:46` must become `IChessEngine?` before `CustomChessEngine` can be slotted in. Both specialists flagged it.
3. **Native-lib naming must be consistent end to end.** The logical name `chess_engine` (C# `[LibraryImport]`/resolver), the CMake `OUTPUT_NAME chess_engine`, and the committed filenames `chess_engine.dll` / `libchess_engine.so` must all agree. The specialists used different names — standardized here.
4. **`Resources/` placement + resolver is the load contract.** DevOps's `DllImportResolver` (probing `BaseDirectory/Resources`) and Backend's `NativeMethods.Resolve` are the same mechanism and must be written once (in `CustomChessEngine`/`NativeMethods`). Evidence: `Stockfish.cs:23,30`, `csproj:14-18`.
5. **Build optimization is a shared Performance/DevOps concern.** `-O3 -flto` / `/O2 /GL /LTCG`, `/arch:AVX2`, and a safe Linux `-march` baseline live in the CMakeLists (DevOps) but are motivated by the hot-loop requirement (Performance).
6. **Move-generation single-source-of-truth.** Human moves stay validated by C# `ChessService` (`ChessController.cs:179`); the native engine has its own generator. Two generators risk divergence — consider exposing native `perft` later for cross-validation.
## Risk Assessment
| Risk | Severity | Likelihood | Mitigation | Related Files |
|---|---|---|---|---|
| Linux `.so` fails to load (glibc/libstdc++ mismatch) | High | Med | Build in `ubuntu:22.04` container or static-link libstdc++; capture server `ldd --version` | `deploy.yml:55-58`, CMakeLists |
| Native crash takes down the ASP.NET process | High | Med | The empty stub returns codes, never throws across the boundary; validate FEN in C# first; consider process isolation if instability appears | `chess_engine.cpp`, `CustomChessEngine.cs` |
| Windows `.dll` can't be built in CI (Linux runner) | Med | High (by design) | Commit the `.dll` like the Stockfish `.exe`; optional opt-in local MSBuild target | `deploy.yml:17`, `csproj:14-18` |
| `-march`/`/arch` too aggressive → SIGILL on server | Med | Med | Use a confirmed server baseline; never `-march=native` for committed/CI binaries | CMakeLists, `Stockfish.cs:24-28` |
| P/Invoke can't find the lib (Resources subfolder not searched) | Med | High without resolver | `DllImportResolver` probing `BaseDirectory/Resources` | `CustomChessEngine.cs`, `Stockfish.cs:30` |
| Handle leak / double-free across boundary | Med | Low | `SafeHandle` + caller-owned out buffers + `delete nullptr`-safe `engine_destroy` | `CustomChessEngine.cs`, `chess_engine.cpp` |
| Concurrent `GetBestMoveAsync` on one non-reentrant handle | Low | Low | One handle per game (as today); document non-reentrancy | `GameState.cs:46`, `ChessController.cs:192` |
| Two move generators (C# rules vs native) diverge | Med | Med | Keep C# as legality authority; add native `perft` for cross-check later | `ChessService.cs`, native |
## Recommendations
Ordered by priority:
1. **Introduce `IChessEngine` and retrofit `Stockfish`** (Architect §). Add the interface, make `Stockfish` implement it, rename `StockfishHelpers``ChessEngineHelpers`, retype `GameState.Computer` to `IChessEngine?`. Low effort, unblocks everything. *Supported by Findings A1A2, B2.*
2. **Lift orchestration out of the engine** into `IComputerMoveOrchestrator` (Architect §; Backend Finding 3). Removes duplication before a second engine exists. Low effort.
3. **Add the factory + `appsettings` swap** (Architect §). `IChessEngineFactory` + `ChessEngineOptions`, registered in `Program.cs`. The swap mechanism the mission asks for. Low effort.
4. **Stand up the native project + empty stub** (Backend + DevOps §). `native/chess_engine/` with `chess_engine.h`, the compilable `chess_engine.cpp` placeholder, and `CMakeLists.txt`. Medium effort (build setup, not logic).
5. **Write `CustomChessEngine` P/Invoke wrapper** (Backend §) implementing `IChessEngine`, with `[LibraryImport]`, `DllImportResolver``Resources/`, `SafeHandle`, and `Task.Run` async adaptation. Medium effort.
6. **Build + commit both artifacts; verify the round trip** (DevOps §). Build `.dll` on Windows / `.so` on Linux (container), commit to `Resources/`, flip `appsettings` to `Custom`, confirm the stub's `e2e4` flows through `ToMoveDto` → SignalR end to end. Medium effort. **Do this before writing any chess logic.**
7. **Then implement the C++ engine** (user) — bitboards, search, eval, all native, honoring the "zero managed callbacks per node" rule (Performance §). The infrastructure above makes this a pure C++ task behind a stable contract.
## Appendix: All Referenced Files
| File | Referenced By | Context |
|---|---|---|
| `Services/Implementations/Stockfish.cs` | Architect, Backend, Performance, DevOps | The engine to mirror; `GetBestMoveAsync`/`MakeMove`/`ToFen`/`ToMoveDto`, process launch, Resources path |
| `Services/Interfaces/IChessService.cs` | Architect, Backend | Rules engine (separate concern, not swapped) |
| `Services/Implementations/ChessService.cs` | Architect, Performance | C# move-gen/legality — the genuinely slow code being replaced |
| `Models/GameState.cs` | Architect, Backend, Performance | `Computer` coupling (`:46`), board model (`:11`) |
| `Models/ChessPiece.cs`, `Position.cs`, `MoveDto.cs`, `Enums.cs` | Backend, Performance | Data shapes; UCI→`MoveDto` parsing |
| `Controllers/ChessController.cs` | Architect, Backend, Performance | Engine construction (`:45`), invocation (`:60,192`), disposal (`:248`), game registry (`:20`) |
| `Program.cs` | Architect | DI registration site (`:21-23`) |
| `appsettings.json` | Architect | Swap config section |
| `Services/Interfaces/IBackgroundTaskQueue.cs` | Performance | Background move execution |
| `JoshHeaps.Net.csproj` | Backend, DevOps | `Content Resources/**` copy (`:14-18`), TFM/Nullable (`:3-7`) |
| `Resources/stockfish-*` | DevOps | Committed-binary precedent for `.dll`/`.so` |
| `.github/workflows/deploy.yml`, `dotnet.yml` | DevOps | Linux-only CI, publish + rsync deploy |
| `native/chess_engine/include/chess_engine.h` (new) | Backend, DevOps | extern "C" ABI + export macro |
| `native/chess_engine/src/chess_engine.cpp` (new) | Backend | Empty compilable stub |
| `native/chess_engine/CMakeLists.txt` (new) | DevOps, Performance | Shared-lib build + optimization flags |
| `Services/Implementations/CustomChessEngine.cs` (new) | Backend, Architect | P/Invoke middleman implementing `IChessEngine` |
| `Services/Interfaces/IChessEngine.cs` + `IChessEngineFactory.cs` (new) | Architect | Swappable contract + factory |
| `Services/Implementations/ChessEngineFactory.cs` + `ComputerMoveOrchestrator.cs` (new) | Architect | Config-driven selection + lifted orchestration |
+42
View File
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.20)
project(chess_engine LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux).
add_library(chess_engine SHARED
src/chess_engine.cpp
src/bitboard.cpp
src/zobrist.cpp
src/position.cpp
src/movegen.cpp
src/uci.cpp
src/perft.cpp)
target_include_directories(chess_engine PUBLIC include)
target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD)
set_target_properties(chess_engine PROPERTIES
OUTPUT_NAME chess_engine
POSITION_INDEPENDENT_CODE ON) # -fPIC on Linux (required for .so)
# Export only the symbols marked with the CHESS_API macro.
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
if (MSVC)
target_compile_options(chess_engine PRIVATE
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
target_link_options(chess_engine PRIVATE
$<$<CONFIG:Release>:/LTCG>
$<$<CONFIG:Debug>:/DEBUG>)
else()
# Portable server baseline. Do NOT use -march=native: the build host may have
# instructions the server lacks (SIGILL at runtime). Bump only once the server
# CPU floor is confirmed.
target_compile_options(chess_engine PRIVATE
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2>
$<$<CONFIG:Debug>:-O0 -g>)
endif()
+115
View File
@@ -0,0 +1,115 @@
<#
.SYNOPSIS
Times the search before vs after killer-move ordering.
.DESCRIPTION
Compiles two standalone bench binaries from the current engine source — one with killer
ordering disabled (the pre-killer baseline, via /DBENCH_DISABLE_KILLERS) and one with it
on — then runs each benchmark position in its own process (cold transposition table) and
reports wall-clock search time for both, plus the speedup. Timing is measured inside the
engine around engine_best_move, so process startup isn't counted. Each position is run
-Reps times and the fastest run is kept, to cut scheduling noise.
.PARAMETER Skill
Search difficulty / max depth. Default 8.
.PARAMETER Positions
Which position indices to run (0=kiwipete, 1=ruy, 2=sicilian). Default: all.
.PARAMETER Reps
Runs per position per variant; the minimum time is reported. Default 3.
.PARAMETER ShowDepths
Also print every per-depth line the engine emits.
.EXAMPLE
.\bench.ps1
.\bench.ps1 -Skill 9 -Reps 5 -Positions 0,2
#>
[CmdletBinding()]
param(
[int]$Skill = 8,
[int[]]$Positions,
[int]$Reps = 3,
[switch]$ShowDepths
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$src = Join-Path $root 'src'
$inc = Join-Path $root 'include'
$build = Join-Path $root 'build'
if (-not (Test-Path $build)) { New-Item -ItemType Directory -Path $build | Out-Null }
# --- enter a VS dev shell so cl is on PATH ---
$devShell = "D:\Program Files\Visual Studio 2026\Common7\Tools\Launch-VsDevShell.ps1"
if (-not (Test-Path $devShell)) { throw "VS dev shell not found at $devShell" }
& $devShell -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null
$engineSources = 'chess_engine.cpp','bitboard.cpp','zobrist.cpp','position.cpp','movegen.cpp','uci.cpp' |
ForEach-Object { Join-Path $src $_ }
$benchMain = Join-Path $root 'test\bench_main.cpp'
function Build-Variant([string]$exe, [string[]]$extraDefs) {
$clArgs = @('/nologo','/std:c++17','/O2','/EHsc','/arch:AVX2','/DCHESS_ENGINE_BUILD','/DNDEBUG') +
$extraDefs + @("/I$inc","/I$src", $benchMain) + $engineSources + @("/Fe:$exe", "/Fo:$build\")
& cl @clArgs | Out-Null
if (-not (Test-Path $exe)) { throw "compile failed: $exe" }
}
$exeBefore = Join-Path $build 'bench_before.exe' # killers disabled (pre-killer baseline)
$exeAfter = Join-Path $build 'bench_after.exe' # killers enabled (current)
Write-Host "Compiling both variants..." -ForegroundColor Cyan
Build-Variant $exeBefore @('/DBENCH_DISABLE_KILLERS')
Build-Variant $exeAfter @()
$nodePattern = '^depth (\d+) nodes (\d+) best (\S+) score (-?\d+)$'
$timePattern = 'time_ms=(\d+)'
function Run-One([string]$exe, [int]$pos) {
$nodes = 0; $best = '?'; $bestMs = [long]::MaxValue
for ($r = 0; $r -lt $Reps; $r++) {
$err = [System.IO.Path]::GetTempFileName()
$out = [System.IO.Path]::GetTempFileName()
Start-Process -FilePath $exe -ArgumentList $pos,$Skill -NoNewWindow -Wait `
-RedirectStandardError $err -RedirectStandardOutput $out | Out-Null
$lines = Get-Content $err
Remove-Item $err,$out -Force -ErrorAction SilentlyContinue
if ($ShowDepths) { $lines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } }
$deepest = $lines | Select-String $nodePattern | Select-Object -Last 1
if ($deepest) { $nodes = [long]$deepest.Matches.Groups[2].Value; $best = $deepest.Matches.Groups[3].Value }
$tm = $lines | Select-String $timePattern | Select-Object -Last 1
if ($tm) { $ms = [long]$tm.Matches.Groups[1].Value; if ($ms -lt $bestMs) { $bestMs = $ms } }
}
return [pscustomobject]@{ Nodes = $nodes; Best = $best; Ms = $bestMs }
}
if (-not $Positions) { $Positions = 0,1,2 }
$names = @('kiwipete','ruy','sicilian')
$rows = @()
foreach ($i in $Positions) {
$b = Run-One $exeBefore $i
$a = Run-One $exeAfter $i
$speedup = if ($a.Ms -gt 0) { [math]::Round($b.Ms / $a.Ms, 2) } else { 0 }
$rows += [pscustomobject]@{
Position = $names[$i]
'ms (before)' = $b.Ms
'ms (after)' = $a.Ms
Speedup = "${speedup}x"
'nodes before' = $b.Nodes
'nodes after' = $a.Nodes
}
}
Write-Host ""
Write-Host "Skill $Skill, best of $Reps runs (before = no killers, after = killers)" -ForegroundColor Cyan
$rows | Format-Table -AutoSize
$tb = ($rows | Measure-Object -Property 'ms (before)' -Sum).Sum
$ta = ($rows | Measure-Object -Property 'ms (after)' -Sum).Sum
$tot = if ($ta -gt 0) { [math]::Round($tb / $ta, 2) } else { 0 }
Write-Host ("TOTAL {0} ms -> {1} ms ({2}x faster)" -f $tb, $ta, $tot)

Some files were not shown because too many files have changed in this diff Show More