Author SHA1 Message Date
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-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-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
jheaps 3da59f9e93 Fix tests 2025-09-24 14:39:52 -06:00
jheaps 4bc69d1989 UPdate to use config options 2025-09-24 14:23:36 -06:00
jheaps c98a78d642 Use config options 2025-09-24 14:20:13 -06:00
jheaps b6990a30b7 Ignore https errors 2025-09-24 14:15:03 -06:00
jheaps 86888b21d2 stop using headed browsers 2025-09-24 14:11:43 -06:00
jheaps 140fbbfa76 update 2025-09-24 14:08:15 -06:00
jheaps a176e29388 update action 2025-09-24 14:06:03 -06:00
jheaps b425c4d5d6 Add playwright tests with github actions 2025-09-24 13:48:20 -06:00
Josh Heaps 3b91055666 Merge pull request #6 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-09-19 16:31:17 -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
Josh Heaps eb3d15527a Merge pull request #4 from JoshHeaps/UpdateParticleSimulatorButton
aewpoifhjp
2025-09-02 11:15:27 -06:00
Josh Heaps 636f133288 Merge pull request #3 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-08-29 16:31:21 -06:00
112 changed files with 7797 additions and 475 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(dotnet test:*)",
"Bash(dotnet build)",
"Bash(dir:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)"
],
"deny": [],
"ask": []
}
}
+12 -3
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
+2 -2
View File
@@ -19,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
+92
View File
@@ -0,0 +1,92 @@
name: Playwright Tests
on:
pull_request:
branches: [ master, main ]
push:
branches: [ master, main ]
jobs:
playwright-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore JoshHeaps.Net.slnf
- name: Build solution
run: dotnet build JoshHeaps.Net.slnf --no-restore
- name: Install Playwright browsers
run: |
cd JoshHeaps.Net.UiTests
dotnet build
# Install Chromium browser
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
# Install system dependencies manually for Ubuntu 24.04 compatibility
sudo apt-get update
sudo apt-get install -y \
libasound2t64 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libxss1 \
libasound2-data \
xvfb
- name: Start server in background
run: |
cd JoshHeaps.Net
dotnet run --urls "https://localhost:7118" &
echo $! > server.pid
# Wait for server to start - try health endpoint first, then chess page
echo "Waiting for server to start..."
timeout 60 bash -c 'until curl -k -s https://localhost:7118/health >/dev/null 2>&1 || curl -k -s https://localhost:7118/chess >/dev/null 2>&1; do echo "Waiting..."; sleep 2; done'
echo "Server appears to be running"
sleep 3
- name: Run Playwright tests
run: |
cd JoshHeaps.Net.UiTests
xvfb-run -a dotnet test --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed"
env:
CI: true
GITHUB_ACTIONS: true
DISPLAY: :99
- name: Stop server
if: always()
run: |
if [ -f JoshHeaps.Net/server.pid ]; then
kill $(cat JoshHeaps.Net/server.pid) || true
fi
pkill -f "dotnet run" || true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: |
JoshHeaps.Net.UiTests/TestResults/
JoshHeaps.Net.UiTests/test-results/
retention-days: 7
- name: Upload Playwright videos
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-videos
path: JoshHeaps.Net.UiTests/test-results/videos/
retention-days: 7
+3
View File
@@ -361,3 +361,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/
+132
View File
@@ -0,0 +1,132 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
using System.Text.Json;
namespace JoshHeaps.Net.UiTests;
[TestFixture]
public class ApiTests : PageTest
{
private IAPIRequestContext? _apiContext;
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]
public async Task Setup()
{
_apiContext = await Playwright.APIRequest.NewContextAsync(new()
{
BaseURL = Config.Test.BaseUrl,
IgnoreHTTPSErrors = true
});
}
[TearDown]
public async Task TearDown()
{
if (_apiContext != null)
{
await _apiContext.DisposeAsync();
}
}
[Test]
public async Task JoinGame_Returns_Valid_Response()
{
var response = await _apiContext!.GetAsync("/api/chess/JoinGame");
Assert.That(response.Status, Is.EqualTo(200));
var jsonResponse = await response.JsonAsync();
var gameData = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
Assert.That(gameData.TryGetProperty("id", out _), Is.True);
Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True);
Assert.That(gameData.TryGetProperty("gameId", out _), Is.True);
}
[Test]
public async Task CreateCPUGame_Returns_Valid_Response()
{
var response = await _apiContext!.GetAsync("/api/chess/new/5");
Assert.That(response.Status, Is.EqualTo(200));
var jsonResponse = await response.JsonAsync();
var gameData = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
Assert.That(gameData.TryGetProperty("id", out _), Is.True);
Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True);
Assert.That(gameData.TryGetProperty("gameId", out _), Is.True);
}
[Test]
public async Task GetGameState_Returns_Valid_Game_Data()
{
// First create a game
var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame");
var createData = JsonSerializer.Deserialize<JsonElement>((await createResponse.JsonAsync()).ToString()!);
var gameId = createData.GetProperty("gameId").GetString();
// Then get the game state
var response = await _apiContext.GetAsync($"/api/chess/{gameId}");
Assert.That(response.Status, Is.EqualTo(200));
var jsonResponse = await response.JsonAsync();
var gameState = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
Assert.That(gameState.TryGetProperty("gameId", out _), Is.True);
Assert.That(gameState.TryGetProperty("currentPlayer", out _), Is.True);
Assert.That(gameState.TryGetProperty("pieces", out var pieces), Is.True);
// Should have 32 pieces initially
Assert.That(pieces.GetArrayLength(), Is.EqualTo(32));
}
[Test]
public async Task GetLegalMoves_Returns_Valid_Moves()
{
// First create a game
var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame");
var createData = JsonSerializer.Deserialize<JsonElement>((await createResponse.JsonAsync()).ToString()!);
var gameId = createData.GetProperty("gameId").GetString();
// Get game state to find a piece
var gameStateResponse = await _apiContext.GetAsync($"/api/chess/{gameId}");
var gameState = JsonSerializer.Deserialize<JsonElement>((await gameStateResponse.JsonAsync()).ToString()!);
var pieces = gameState.GetProperty("pieces");
var firstPiece = pieces[0];
var pieceId = firstPiece.GetProperty("id").GetString();
// Get legal moves for the piece
var response = await _apiContext.GetAsync($"/api/chess/{gameId}/legalMoves/{pieceId}");
Assert.That(response.Status, Is.EqualTo(200));
var moves = await response.JsonAsync();
Assert.That(moves, Is.Not.Null);
}
[Test]
public async Task Invalid_GameId_Returns_NotFound()
{
var invalidGameId = Guid.NewGuid().ToString();
var response = await _apiContext!.GetAsync($"/api/chess/{invalidGameId}");
Assert.That(response.Status, Is.EqualTo(404));
}
}
+121
View File
@@ -0,0 +1,121 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
[TestFixture]
public class ChessGameTests : PageTest
{
private TestConfiguration Config => TestConfiguration.Instance;
public override BrowserNewContextOptions ContextOptions()
{
return Config.GetBrowserContextOptions();
}
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
// Set Playwright browser options for headless control
if (!Config.Playwright.Headless)
{
Environment.SetEnvironmentVariable("HEADED", "1");
}
if (Config.Playwright.SlowMotion > 0)
{
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
}
}
[SetUp]
public async Task Setup()
{
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
protected async Task WaitDefault()
{
await Page.WaitForTimeoutAsync(Config.Test.WaitTimeout);
}
[Test]
public async Task Chess_Page_Loads_Successfully()
{
await Expect(Page.Locator("h1")).ToContainTextAsync("Chess");
await Expect(Page.Locator("#startGameBtn")).ToBeVisibleAsync();
await Expect(Page.Locator("#startCPUGame")).ToBeVisibleAsync();
await Expect(Page.Locator("#chessBoard")).ToBeVisibleAsync();
}
[Test]
public async Task Chess_Board_Has_64_Squares()
{
var squares = Page.Locator(".chessSquare");
await Expect(squares).ToHaveCountAsync(64);
}
[Test]
public async Task Start_New_Game_Button_Works()
{
await Page.ClickAsync("#startGameBtn");
// Wait for game to start (pieces should appear)
await Page.Locator(".chessPiece").First.WaitForAsync();
// Check that pieces have been rendered
var pieces = Page.Locator(".chessPiece");
await Expect(pieces).ToHaveCountAsync(32); // Standard chess has 32 pieces
}
[Test]
public async Task Start_CPU_Game_Shows_Difficulty_Modal()
{
await Page.ClickAsync("#startCPUGame");
// Should show difficulty modal
await Expect(Page.Locator("#difficultyModal")).ToBeVisibleAsync();
await Expect(Page.Locator("#difficultyModal p")).ToContainTextAsync("Set bot difficulty to:");
// Should have difficulty buttons 1-20
var difficultyButtons = Page.Locator("#difficultyButtonContainer button");
await Expect(difficultyButtons).ToHaveCountAsync(20);
}
[Test]
public async Task CPU_Game_Starts_After_Selecting_Difficulty()
{
await Page.ClickAsync("#startCPUGame");
// Select difficulty 5
await Page.ClickAsync("#difficultyButtonContainer button:nth-child(5)");
// Modal should close
await Expect(Page.Locator("#difficultyModal")).ToBeHiddenAsync();
// Wait for game to start
await Page.Locator(".chessPiece").First.WaitForAsync();
// Check that pieces have been rendered
var pieces = Page.Locator(".chessPiece");
await Expect(pieces).ToHaveCountAsync(32);
}
[Test]
public async Task Pieces_WhenClicked_HighlightLegalMoves()
{
// Start a new game first
await Page.ClickAsync("#startGameBtn");
// Wait for pieces to be rendered
await Page.Locator(".chessPiece").First.WaitForAsync();
// Find any white piece and click it
var pawn = Page.Locator("#chessBoard div:has(img[src*='WhitePawn'])").First;
await pawn.ClickAsync();
var legalMoves = Page.Locator(".legal");
await Expect(legalMoves).ToHaveCountAsync(2);
}
}
+190
View File
@@ -0,0 +1,190 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
[TestFixture]
public class GameplayTests : PageTest
{
private TestConfiguration Config => TestConfiguration.Instance;
public override BrowserNewContextOptions ContextOptions()
{
return Config.GetBrowserContextOptions();
}
[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]
public async Task Setup()
{
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
[Test]
public async Task Scholars_Mate_Results_In_Checkmate()
{
// Start a multiplayer game
await Page.ClickAsync("#startGameBtn");
await Page.WaitForSelectorAsync(".chessPiece");
if (await Page.Locator("#square-55:has(img[src='/images/Chess Images/WhitePawn.svg'])").CountAsync() == 0)
{
await Page.ClickAsync("#startGameBtn");
await Page.WaitForSelectorAsync(".chessPiece");
}
// Create a second browser context for the second player
await using var browser = await Playwright.Chromium.LaunchAsync();
await using var context2 = await browser.NewContextAsync(ContextOptions());
var page2 = await context2.NewPageAsync();
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
await page2.ClickAsync("#startGameBtn");
await page2.WaitForSelectorAsync(".chessPiece");
/*
* Scholar's Mate sequence:
* 1. e4 e5
* 2. Bc4 Nc6
* 3. Qh5 Nf6??
* 4. Qxf7# (checkmate)
*
* In source square to destination square, accounting for black ui swapping:
* 1. 52-36 51-35
* 2. 61-34 58-37
* 3. 59-45 62-45
* 4. 45-13
*/
// Move 1: White plays e4 (pawn e2-e4)
await MakeMove(Page, 52, 36); // e2 to e4
// Move 1: Black plays e5 (pawn e7-e5)
await MakeMove(page2, 51, 35); // e7 to e5
// Move 2: White plays Bc4 (bishop f1-c4)
await MakeMove(Page, 61, 34); // f1 to c4
// Move 2: Black plays Nc6 (knight b8-c6)
await MakeMove(page2, 58, 37); // b8 to c6
// Move 3: White plays Qh5 (queen d1-h5)
await MakeMove(Page, 59, 45); // d1 to h5
// Move 3: Black plays Nf6 (knight g8-f6) - the blunder
await MakeMove(page2, 62, 45); // g8 to f6
string? alertMessage = null;
Page.Dialog += async (_, dialog) =>
{
alertMessage = dialog.Message;
await dialog.AcceptAsync();
};
// Move 4: White plays Qxf7# (queen h5-f7, checkmate)
await MakeMove(Page, 45, 13); // h5 to f7
// The test passes if we can execute all moves without errors
// Specific checkmate verification depends on how your UI handles game end
Assert.That(alertMessage, Is.Not.Null);
Assert.That(alertMessage, Contains.Substring("Checkmate"));
}
[Test]
public async Task Pawn_Promotion_Shows_Modal()
{
// Start a multiplayer game
await Page.ClickAsync("#startGameBtn");
await Page.WaitForSelectorAsync(".chessPiece");
if (await Page.Locator("#square-55:has(img[src='/images/Chess Images/WhitePawn.svg'])").CountAsync() == 0)
{
await Page.ClickAsync("#startGameBtn");
await Page.WaitForSelectorAsync(".chessPiece");
}
// Create a second browser context for the second player (black)
await using var browser = await Playwright.Chromium.LaunchAsync();
await using var context2 = await browser.NewContextAsync(ContextOptions());
var page2 = await context2.NewPageAsync();
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
await page2.ClickAsync("#startGameBtn");
await page2.WaitForSelectorAsync(".chessPiece");
// Exact sequence: 1. h4 g5 2. hxg5 h6 3. gxh6 a6 4. h7 a5 5. hxg8=Q
// in square numbers: 55-39 49-33 39-30 48-40 30-23 55-47 23-15 47-39 15-6
// Move 1: White h4 (h2-h4, square 55 to 39)
await MakeMove(Page, 55, 39); // h2 to h4
// Move 1: Black g5 (g7-g5, square 14 to 30)
await MakeMove(page2, 49, 33); // g7 to g5
// Move 2: White hxg5 (h4 captures g5, square 39 to 30)
await MakeMove(Page, 39, 30); // h4 captures g5
// Move 2: Black h6 (h7-h6, square 15 to 23)
await MakeMove(page2, 48, 40); // h7 to h6
// Move 3: White gxh6 (g5 captures h6, square 30 to 23)
await MakeMove(Page, 30, 23); // g5 captures h6
// Move 3: Black a6 (a7-a6, square 8 to 16)
await MakeMove(page2, 55, 47); // a7 to a6
// Move 4: White h7 (h6-h7, square 23 to 15)
await MakeMove(Page, 23, 15); // h6 to h7
// Move 4: Black a5 (a6-a5, square 16 to 24)
await MakeMove(page2, 47, 39); // a6 to a5
// Move 5: White hxg8=Q (h7 captures g8 and promotes, square 15 to 6)
await MakeMove(Page, 15, 6); // h7 captures g8 (promotion)
// Check if promotion modal appears
var promotionModal = Page.Locator("#promotionModal");
var queenOption = Page.Locator("button:has(img[alt='Queen'])");
var promotedQueenSquare = Page.Locator("#square-6:has(img[src='/images/Chess Images/WhiteQueen.svg'])");
var isModalVisible = await promotionModal.IsVisibleAsync();
await Expect(promotionModal).ToBeVisibleAsync();
await Expect(queenOption).ToBeVisibleAsync();
await queenOption.ClickAsync();
await promotionModal.WaitForAsync(new() { State = WaitForSelectorState.Hidden });
await Expect(promotedQueenSquare).ToBeVisibleAsync();
}
private async Task MakeMove(IPage page, int fromSquare, int toSquare)
{
// Click on the source square/piece
var fromSquareElement = page.Locator($"#square-{fromSquare}");
var piece = fromSquareElement.Locator(".chessPiece");
if (await piece.CountAsync() > 0)
{
await piece.ClickAsync();
await page.WaitForTimeoutAsync(500);
}
// Click on the destination square
var toSquareElement = page.Locator($"#square-{toSquare}");
await toSquareElement.ClickAsync();
await page.WaitForTimeoutAsync(1000);
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.27.1" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.Playwright.NUnit" />
<Using Include="NUnit.Framework" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Threading.Tasks" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.Integration.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.CI.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+84
View File
@@ -0,0 +1,84 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
[TestFixture]
public class MultiplayerTests : PageTest
{
private TestConfiguration Config => TestConfiguration.Instance;
public override BrowserNewContextOptions ContextOptions()
{
return Config.GetBrowserContextOptions();
}
[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]
public async Task Setup()
{
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
[Test]
public async Task Two_Players_Can_Join_Same_Game()
{
// This test requires running two browser contexts
await using var browser = await Playwright.Chromium.LaunchAsync();
await using var context1 = await browser.NewContextAsync(ContextOptions());
await using var context2 = await browser.NewContextAsync(ContextOptions());
var page1 = await context1.NewPageAsync();
var page2 = await context2.NewPageAsync();
await page1.GotoAsync($"{Config.Test.BaseUrl}/chess");
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
// Player 1 starts a game
await page1.ClickAsync("#startGameBtn");
await page1.Locator(".chessPiece").First.WaitForAsync();
// Player 2 joins the same game
await page2.ClickAsync("#startGameBtn");
await page2.Locator(".chessPiece").First.WaitForAsync();
// Both players should see pieces
var pieces1 = page1.Locator(".chessPiece");
var pieces2 = page2.Locator(".chessPiece");
await Expect(pieces1).ToHaveCountAsync(32);
await Expect(pieces2).ToHaveCountAsync(32);
}
[Test]
public async Task SignalR_Connection_Established()
{
// Start a game to trigger SignalR connection
await Page.ClickAsync("#startGameBtn");
await Page.Locator(".chessPiece").First.WaitForAsync();
// Check browser console for SignalR connection message
var consoleLogs = new List<string>();
Page.Console += (_, e) => consoleLogs.Add(e.Text);
await Page.ReloadAsync();
await Page.WaitForTimeoutAsync(1000);
// Should see SignalR connected message in console
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
}
}
+61
View File
@@ -0,0 +1,61 @@
# JoshHeaps.Net UI Tests
This project contains end-to-end tests for the JoshHeaps.Net website using Playwright.
## Setup
1. Install Playwright browsers:
```bash
# From the test project directory
dotnet build
# Then install browsers (requires PowerShell or appropriate command for your OS)
bin/Debug/net8.0/playwright.cmd install # Windows
```
2. Make sure the main application is running on `https://localhost:7065`
## Running Tests
```bash
# Run all tests
dotnet test
# Run specific test file
dotnet test --filter "FullyQualifiedName~ChessGameTests"
# Run with verbose output
dotnet test --logger "console;verbosity=detailed"
```
## Test Categories
### ChessGameTests
- Basic chess page functionality
- Game start/stop operations
- Piece interaction and move validation
- UI element verification
### MultiplayerTests
- Two-player game scenarios
- SignalR connection testing
- Real-time move synchronization
### ApiTests
- Chess API endpoint testing
- Game creation and state management
- Move validation at API level
- Error handling
## Configuration
The tests are configured to:
- Use headless browsers by default
- Take screenshots on failure
- Record videos on failure
- Automatically start the web server if needed (port 7065)
## Notes
- Tests expect the main application to be available at `https://localhost:7065`
- Some tests require browsers to be installed via Playwright CLI
- Browser installations may require PowerShell on Windows systems
@@ -0,0 +1,97 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Playwright;
namespace JoshHeaps.Net.UiTests;
public class TestConfiguration
{
private static TestConfiguration? _instance;
private static readonly object _lock = new object();
public PlaywrightSettings Playwright { get; }
public TestSettings Test { get; }
private TestConfiguration()
{
var isCI = Environment.GetEnvironmentVariable("CI") == "true" ||
Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true";
var configFile = isCI ? "appsettings.CI.json" : "appsettings.Integration.json";
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile(configFile, optional: true, reloadOnChange: false)
.AddJsonFile("appsettings.Integration.json", optional: true, reloadOnChange: false) // fallback
.Build();
Playwright = configuration.GetSection("PlaywrightSettings").Get<PlaywrightSettings>() ?? new PlaywrightSettings();
Test = configuration.GetSection("TestSettings").Get<TestSettings>() ?? new TestSettings();
}
public static TestConfiguration Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new TestConfiguration();
}
}
}
return _instance;
}
}
public BrowserNewContextOptions GetBrowserContextOptions()
{
return new BrowserNewContextOptions
{
ViewportSize = new ViewportSize
{
Width = Playwright.Viewport.Width,
Height = Playwright.Viewport.Height
},
RecordVideoDir = Playwright.EnableVideoRecording ? Path.Combine(Directory.GetCurrentDirectory(), "test-results", "videos") : null,
IgnoreHTTPSErrors = Playwright.IgnoreHTTPSErrors
};
}
public BrowserTypeLaunchOptions GetLaunchOptions()
{
return new BrowserTypeLaunchOptions
{
Headless = Playwright.Headless,
SlowMo = Playwright.SlowMotion,
Timeout = Playwright.BrowserTimeout
};
}
}
public class PlaywrightSettings
{
public bool Headless { get; set; } = true;
public float SlowMotion { get; set; } = 0;
public ViewportSettings Viewport { get; set; } = new();
public float BrowserTimeout { get; set; } = 30000;
public float ActionTimeout { get; set; } = 10000;
public bool EnableVideoRecording { get; set; } = false;
public bool IgnoreHTTPSErrors { get; set; } = false;
}
public class ViewportSettings
{
public int Width { get; set; } = 1280;
public int Height { get; set; } = 720;
}
public class TestSettings
{
public string BaseUrl { get; set; } = "https://localhost:7118";
public int WaitTimeout { get; set; } = 3000;
public bool EnableScreenshots { get; set; } = true;
public bool EnableVideoRecording { get; set; } = false;
}
View File
+20
View File
@@ -0,0 +1,20 @@
{
"PlaywrightSettings": {
"Headless": true,
"SlowMotion": 0,
"Viewport": {
"Width": 1280,
"Height": 720
},
"BrowserTimeout": 60000,
"ActionTimeout": 30000,
"EnableVideoRecording": true,
"IgnoreHTTPSErrors": true
},
"TestSettings": {
"BaseUrl": "https://localhost:7118",
"WaitTimeout": 5000,
"EnableScreenshots": true,
"EnableVideoRecording": true
}
}
@@ -0,0 +1,19 @@
{
"PlaywrightSettings": {
"Headless": false,
"SlowMotion": 500,
"Viewport": {
"Width": 1280,
"Height": 720
},
"BrowserTimeout": 30000,
"ActionTimeout": 10000,
"IgnoreHTTPSErrors": true
},
"TestSettings": {
"BaseUrl": "https://localhost:7118",
"WaitTimeout": 3000,
"EnableScreenshots": true,
"EnableVideoRecording": true
}
}
@@ -0,0 +1,24 @@
{
"use": {
"headless": true,
"viewport": { "width": 1280, "height": 720 },
"ignoreHTTPSErrors": true,
"video": "retain-on-failure",
"screenshot": "only-on-failure"
},
"projects": [
{
"name": "chromium",
"use": { "browserName": "chromium" }
},
{
"name": "firefox",
"use": { "browserName": "firefox" }
}
],
"webServer": {
"command": "dotnet run --project ../JoshHeaps.Net",
"port": 7065,
"reuseExistingServer": true
}
}
+41 -7
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}") = "Tests", "Tests\Tests.csproj", "{82206AD3-19DF-4DDA-9647-02B691B78CE8}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
EndProject 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
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.Build.0 = Release|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
EndGlobalSection 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();
}
}
+181 -41
View File
@@ -1,5 +1,6 @@
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;
@@ -9,14 +10,25 @@ 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,
IHubContext<ChessHub> chessHub) : ControllerBase
{ {
/// <summary> /// <summary>
/// Store of ongoing games. /// Store of ongoing games.
/// </summary> /// </summary>
private static readonly ConcurrentDictionary<Guid, GameState> _games = []; private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = []; private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
/// <summary> /// <summary>
/// Create a new chess game and store it in-memory. /// Create a new chess game and store it in-memory.
@@ -35,7 +47,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
Guid computerId = Guid.NewGuid(); Guid computerId = Guid.NewGuid();
var isWhite = Random.Shared.Next(2) == 0; var isWhite = Random.Shared.Next(2) == 0;
gameState.Computer = new(difficulty); gameState.Computer = engineFactory.Create(difficulty);
if (isWhite) if (isWhite)
{ {
@@ -50,11 +62,11 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
{ {
// 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, gameState.Computer!);
}); });
} }
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1)); ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -64,6 +76,31 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
}); });
} }
/// <summary>
/// Create a game the computer plays against itself and auto-play it move by move,
/// broadcasting each move so it can be watched on the spectator page.
/// </summary>
[HttpGet("watch/cpu")]
[HttpGet("watch/cpu/{difficulty}")]
public ActionResult CreateSelfPlayGame(int difficulty = 4)
{
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameState.IsVsComputer = true;
gameState.IsComputerVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid();
gameState.Computer = engineFactory.Create(difficulty);
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
StartSelfPlay(gameState);
return Ok(new { gameState.GameId });
}
/// <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
@@ -96,7 +133,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
isWhite = false; isWhite = false;
} }
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1)); ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -106,6 +143,30 @@ 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 = _games.Values
// 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,
CurrentPlayer = g.CurrentPlayer.ToString(),
MoveCount = g.MoveHistory.Count,
g.IsCheck
})
.OrderByDescending(g => g.MoveCount);
return Ok(activeGames);
}
/// <summary> /// <summary>
/// Get the state of an existing game by ID. /// Get the state of an existing game by ID.
/// </summary> /// </summary>
@@ -122,6 +183,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
gameState.IsCheck, gameState.IsCheck,
gameState.IsCheckmate, gameState.IsCheckmate,
gameState.IsStalemate, gameState.IsStalemate,
gameState.IsThreefoldRepetition,
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null, EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
gameState.WhiteCanCastleKingside, gameState.WhiteCanCastleKingside,
gameState.WhiteCanCastleQueenside, gameState.WhiteCanCastleQueenside,
@@ -158,7 +220,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,33 +229,70 @@ 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
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
}
else
{
// increase timeout if play continues.
if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
else
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
}
if (gameState.IsVsComputer && gameState.Computer is not null) if (isGameOver)
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService)); ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
else if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
else
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
return Ok(result); return Ok(result);
} }
/// <summary>
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
/// Used when a player abandons a game (e.g. starts a new one mid-game).
/// </summary>
[HttpPost("forfeit")]
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
{
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
return NotFound("Game not found");
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
return Ok();
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
if (!isWhitePlayer && !isBlackPlayer)
return StatusCode(403, "You are not a player in this game.");
gameState.IsForfeited = true;
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
ScheduleRemoveGame(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 (!_games.TryGetValue(gameId, out var gameState))
return NotFound("Game not found");
return Content(gameState.ToPgn(), "application/x-chess-pgn");
}
/// <summary> /// <summary>
/// Get the legal moves for a specific piece in a specific game. /// Get the legal moves for a specific piece in a specific game.
/// </summary> /// </summary>
@@ -227,33 +326,74 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
return Ok(allMoves); return Ok(allMoves);
} }
/// <summary>
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's
/// move (which applies and broadcasts it) until the game ends or is removed.
/// </summary>
private void StartSelfPlay(GameState gameState)
{
queue.Queue(async () =>
{
// Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(TimeSpan.FromSeconds(1));
while (_games.ContainsKey(gameState.GameId)
&& !gameState.IsCheckmate
&& !gameState.IsStalemate
&& !gameState.IsThreefoldRepetition
&& !gameState.IsForfeited)
{
try
{
await orchestrator.PlayAsync(gameState, gameState.Computer!);
}
catch (Exception ex)
{
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
break;
}
await Task.Delay(_selfPlayMoveDelay);
}
// Leave the finished game in place briefly so spectators can see the result.
if (_games.ContainsKey(gameState.GameId))
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
});
}
private static void ScheduleRemoveGame(Guid id, TimeSpan delay) private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{ {
if (_gameRemovalTasks.ContainsKey(id)) if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
{ {
oldCts.Cancel();
oldCts.Dispose();
}
var cts = new CancellationTokenSource();
_gameRemovalCancellationTokens[id] = cts;
_gameRemovalTasks[id] = Task.Run(async () => _gameRemovalTasks[id] = Task.Run(async () =>
{ {
await Task.Delay(delay); try
{
await Task.Delay(delay, cts.Token);
if (_games[id].Computer is not null) if (_games.TryGetValue(id, out var game) && game.Computer is not null)
await _games[id].Computer!.DisposeAsync(); await game.Computer.DisposeAsync();
_games.Remove(id, out _); _games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _); }
}); catch (OperationCanceledException) { }
finally
return; {
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
{
_gameRemovalCancellationTokens.TryRemove(id, out _);
} }
_gameRemovalTasks.TryAdd(id, Task.Run(async () => cts.Dispose();
{ }
await Task.Delay(delay); });
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
}));
} }
} }
+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; }
}
+20 -2
View File
@@ -1,4 +1,4 @@
using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models; namespace JoshHeaps.Net.Models;
@@ -28,10 +28,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 +57,9 @@ 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; } public IChessEngine? Computer { get; set; }
// optional: convenience // optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined; public bool IsOpen => !WhiteJoined || !BlackJoined;
@@ -54,5 +70,7 @@ public class GameState
Board = new ChessPiece[8, 8]; Board = new ChessPiece[8, 8];
Pieces = []; Pieces = [];
MoveHistory = []; MoveHistory = [];
PositionHistory = [];
SanHistory = [];
} }
} }
+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();
}
}
+10 -1
View File
@@ -24,6 +24,8 @@
<div id="buttonContainer" class="sideContent"> <div id="buttonContainer" class="sideContent">
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button> <button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button> <button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
<a id="watchLink" href="/watch">Watch other games →</a>
</div> </div>
</div> </div>
@@ -59,7 +61,14 @@
@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"></script>
<script src="~/js/ChessScripts/ChessUtils.js"></script>
<script src="~/js/ChessScripts/ChessAPI.js"></script>
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
<script src="~/js/ChessScripts/ChessBoard.js"></script>
<script src="~/js/ChessScripts/ChessModals.js"></script>
<script src="~/js/ChessScripts/chessMain.js"></script>
} }
@section Styles { @section Styles {
+77 -56
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>
<div id="WelcomeMessage"></div> <section id="Hero">
<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>
Particle Simulator <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>
</div> </section>
<section id="BlogBox">
<h2 class="section-title">Latest Posts</h2>
<div id="blogPostsList">
@foreach (var post in Model.LatestPosts)
{
<div class="blog-teaser-item">
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
<p class="blog-teaser-summary">@post.Summary</p>
</div>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &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()
{
}
}
}
+34
View File
@@ -0,0 +1,34 @@
@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">
<label for="cpuDifficulty">Difficulty</label>
<select id="cpuDifficulty">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
</div>
<a id="backToPlay" href="/chess">← Play a game</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()
{
}
}
}
+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.5"; // <--- change this once to bust cache
} }
<!DOCTYPE html> <!DOCTYPE html>
+11
View File
@@ -12,9 +12,20 @@ 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<IChessEngineFactory, ChessEngineFactory>();
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
if (!builder.Environment.IsDevelopment()) if (!builder.Environment.IsDevelopment())
builder.Services.AddHostedService<AutoIpUpdateService>(); builder.Services.AddHostedService<AutoIpUpdateService>();
@@ -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.
@@ -8,31 +8,23 @@ public class AutoIpUpdateService(
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");
@@ -40,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
{ {
@@ -55,7 +69,7 @@ public class AutoIpUpdateService(
} }
} }
private async Task<AAAARecord> GetDnsRecordAsync() private async Task<RecordList> GetDnsRecordAsync()
{ {
try try
{ {
@@ -64,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
{ {
@@ -76,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)
{ {
@@ -102,11 +117,23 @@ public class AutoIpUpdateService(
} }
} }
record AAAARecord( record DnsRecord(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ttl")] int Ttl,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("comment")] string Comment, [property: JsonPropertyName("comment")] string Comment,
[property: JsonPropertyName("content")] string Content, [property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("proxied")] bool Proxied,
[property: JsonPropertyName("id")] string Id); [property: JsonPropertyName("id")] string Id);
record RecordList([property: JsonPropertyName("result")] List<AAAARecord> Result); record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
{
public DnsRecord this[int index]
{
get
{
return Records[index];
}
}
}
} }
@@ -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,32 @@
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>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>Creates the configured <see cref="IChessEngine"/> per game.</summary>
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 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,33 @@
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) : IComputerMoveOrchestrator
{
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
{
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
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);
return (move, result);
}
}
@@ -0,0 +1,119 @@
using JoshHeaps.Net.Services.Interfaces;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace JoshHeaps.Net.Services.Implementations;
/// <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)
{
Skill = skill;
var handle = NativeMethods.engine_create($"skill={skill}");
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).
/// </summary>
private 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);
}
}
@@ -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 "*";
}
}
@@ -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,14 @@
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. 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);
}
@@ -0,0 +1,18 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Drives a computer move: asks the engine for a move, applies it through the rules
/// service, and broadcasts the result to the game's clients.
/// </summary>
public interface IComputerMoveOrchestrator
{
/// <summary>
/// Has the engine pick a move for the current position, applies it, and broadcasts it.
/// </summary>
/// <param name="state">The game to play a move in.</param>
/// <param name="engine">The engine that selects the move.</param>
/// <returns>The applied move and its result.</returns>
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
}
@@ -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;
}
+32 -1
View File
@@ -1,5 +1,10 @@
#chessBoard { #boardContainer {
position: relative;
width: fit-content;
margin: 2vw auto; margin: 2vw auto;
}
#chessBoard {
width: 60vw; width: 60vw;
height: 60vw; height: 60vw;
display: grid; display: grid;
@@ -159,3 +164,29 @@
height: 50px; height: 50px;
pointer-events: none; /* ensures img doesn't steal the click */ pointer-events: none; /* ensures img doesn't steal the click */
} }
.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;
}
+22
View File
@@ -28,6 +28,28 @@
margin: 2vh; margin: 2vh;
} }
#copyPgnBtn {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
#watchLink {
color: #8cd5ed;
text-decoration: none;
margin: 2vh;
order: 2;
}
#watchLink:hover {
text-decoration: underline;
}
#chessContainer { #chessContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -0,0 +1,149 @@
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;
gap: 0.5rem;
margin: 1rem 0;
}
#cpuDifficulty {
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);
}
+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: 20vw; font-size: 0.875rem;
height: 8vw; 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) {
const response = await fetch(`/api/chess/new/${difficulty}`);
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.");
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "Invalid move or not your turn.");
}
return result;
},
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 moveResult = await this.makeMove(moveDto);
const updatedGame = await this.getGameState(GameState.currentGameId);
ChessBoard.renderPieces(updatedGame.pieces);
GameState.clearSelection();
ChessInteractions.clearHighlights();
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
this.alertGameStatusChange(moveResult);
} 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,146 @@
const ChessBoard = {
renderPieces(pieces) {
this.clearAllSquares();
this.renderCoordinateLabels();
this.placePieces(pieces);
this.setupSquareEventHandlers();
this.highlightPreviousMove();
},
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,29 @@
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);
};
});
}
};
console.log("ChessModals.js loaded");
@@ -0,0 +1,75 @@
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) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
});
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) {
if (gameId !== GameState.currentGameId) return;
const gameState = await ChessAPI.getGameState(gameId);
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
ChessBoard.renderPieces(gameState.pieces);
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 notifyMoveMade(moveDto, moveResult) {
if (this.connection) {
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
}
},
async leaveGame() {
if (this.connection) {
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
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,50 @@
const GameState = {
currentGameId: null,
currentPlayerId: null,
currentPlayerIsWhite: null,
selectedPiece: null,
legalMoves: [],
previousMoveStart: null,
previousMoveEnd: null,
reset() {
this.currentGameId = null;
this.currentPlayerId = null;
this.currentPlayerIsWhite = null;
this.selectedPiece = null;
this.legalMoves = [];
this.previousMoveStart = null;
this.previousMoveEnd = null;
},
setGameInfo(gameId, playerId, isWhite) {
this.currentGameId = gameId;
this.currentPlayerId = playerId;
this.currentPlayerIsWhite = isWhite;
},
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,287 @@
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) =>
this.handleMoveUpdate(gameId, moveDto));
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();
setInterval(() => this.refreshGames(), 5000);
},
async startCpuGame() {
const difficulty = document.getElementById("cpuDifficulty").value;
try {
await fetch(`/api/chess/watch/cpu/${difficulty}`);
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
});
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;
const state = await response.json();
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) {
if (!this.games.has(gameId)) return;
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 "CPU vs CPU";
if (stored.isVsComputer) return "Vs CPU";
return "Player vs Player";
},
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,131 @@
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;
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() {
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.renderPieces(gameState.pieces);
} catch (error) {
console.error("Failed to start new game:", error);
}
}
async function startCPUGame() {
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
try {
const difficulty = await ChessModals.promptDifficulty();
const gameData = await ChessAPI.createCPUGame(difficulty);
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.renderPieces(gameState.pieces);
} 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.renderPieces(gameState.pieces);
} 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");
@@ -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)
@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>18.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{2579bbbc-1830-4342-bc10-0a4182dc84c7}</ProjectGuid>
<RootNamespace>chessengine</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="..\src\chess_engine.cpp" />
<ClCompile Include="..\src\bitboard.cpp" />
<ClCompile Include="..\src\zobrist.cpp" />
<ClCompile Include="..\src\position.cpp" />
<ClCompile Include="..\src\movegen.cpp" />
<ClCompile Include="..\src\uci.cpp" />
<ClCompile Include="..\src\perft.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\include\chess_engine.h" />
<ClInclude Include="..\src\types.h" />
<ClInclude Include="..\src\bitboard.h" />
<ClInclude Include="..\src\zobrist.h" />
<ClInclude Include="..\src\position.h" />
<ClInclude Include="..\src\movegen.h" />
<ClInclude Include="..\src\uci.h" />
<ClInclude Include="..\src\perft.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
<Target Name="CopyEngineToResources" AfterTargets="Build">
<!-- Copy the DLL plus its .pdb so native breakpoints bind when the .NET host loads the engine. -->
<Copy SourceFiles="$(TargetPath);$(TargetDir)$(TargetName).pdb" DestinationFolder="$(ProjectDir)..\..\..\JoshHeaps.Net\Resources\" SkipUnchangedFiles="false" />
</Target>
</Project>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="..\src\chess_engine.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\bitboard.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\zobrist.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\position.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\movegen.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\uci.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\perft.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\include\chess_engine.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\types.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\bitboard.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\zobrist.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\position.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\movegen.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\uci.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\src\perft.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
</Project>
@@ -0,0 +1,85 @@
/* chess_engine.h - C ABI for a swappable chess engine.
*
* Contract: FEN string in, UCI move string out (e.g. "e2e4", "e7e8q").
* The C# host (CustomChessEngine) owns all buffers. The engine NEVER allocates
* memory that the host must free. Functions are thread-compatible per-handle only:
* do NOT call two functions on the SAME handle concurrently. Different handles
* are independent.
*/
#ifndef CHESS_ENGINE_H
#define CHESS_ENGINE_H
#include <stddef.h>
/* ---- Export / calling-convention macro (MSVC + GCC/Clang) ---- */
#if defined(_WIN32)
#ifdef CHESS_ENGINE_BUILD
#define CHESS_API __declspec(dllexport)
#else
#define CHESS_API __declspec(dllimport)
#endif
#define CHESS_CALL __cdecl /* explicit; matches C# CallingConvention.Cdecl */
#else
#define CHESS_API __attribute__((visibility("default")))
#define CHESS_CALL /* SysV default; no decoration needed */
#endif
#ifdef __cplusplus
extern "C" { /* prevent C++ name mangling */
#endif
/* Opaque handle. The host treats this as a token and never dereferences it.
* Internally it points to your engine state object. */
typedef struct ChessEngine* EngineHandle;
/* Return codes. 0 == success; negative == error. Keep these values stable. */
enum {
CHESS_OK = 0,
CHESS_ERR_NULL_HANDLE = -1, /* handle was null/invalid */
CHESS_ERR_BAD_FEN = -2, /* fen failed to parse */
CHESS_ERR_NO_MOVE = -3, /* no legal move (mate/stalemate) */
CHESS_ERR_BUFFER = -4, /* out_buf too small for the move + NUL */
CHESS_ERR_INTERNAL = -5 /* unexpected engine failure */
};
/* Create an engine instance.
* options: optional null-terminated UTF-8 config string (may be NULL),
* e.g. "skill=20;hash=256". Parse however you like; ignore for now.
* returns: a valid EngineHandle, or NULL on allocation failure. */
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options);
/* Set a single option by name (optional; may no-op for now).
* returns CHESS_OK or a negative code. */
CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
const char* name,
const char* value);
/* Compute the best move for the given position.
* engine : handle from engine_create.
* fen : null-terminated UTF-8 FEN of the position to move from.
* history : optional null-terminated UTF-8 list of the prior positions since the
* last irreversible move (capture/pawn move), one FEN per line, oldest
* first, NOT including `fen`. Lets the engine detect threefold/50-move
* draws that the FEN alone can't carry. May be NULL or empty.
* out_buf : host-owned buffer the engine writes the UCI move into,
* as a null-terminated ASCII string (e.g. "e2e4\0").
* out_len : capacity of out_buf in bytes (host passes >= 8).
* returns CHESS_OK on success (out_buf now holds the move), else negative.
* MUST NOT write more than out_len bytes including the NUL terminator. */
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
const char* fen,
const char* history,
char* out_buf,
int out_len);
/* Write the engine version string into out_buf (null-terminated).
* returns CHESS_OK or CHESS_ERR_BUFFER. */
CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
/* Destroy an instance created by engine_create. Safe to call with NULL. */
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine);
#ifdef __cplusplus
}
#endif
#endif /* CHESS_ENGINE_H */
+146
View File
@@ -0,0 +1,146 @@
#include "bitboard.h"
#include <cstdlib>
namespace chess {
Bitboard PawnAttacks[COLOR_NB][SQUARE_NB];
Bitboard KnightAttacks[SQUARE_NB];
Bitboard KingAttacks[SQUARE_NB];
Magic BishopMagics[SQUARE_NB];
Magic RookMagics[SQUARE_NB];
// Backing storage the magics index into (fancy-magic sizes).
static Bitboard RookTable[102400];
static Bitboard BishopTable[5248];
namespace {
int file_distance(Square a, Square b) {
return std::abs(int(file_of(a)) - int(file_of(b)));
}
// Slow, edge-aware ray attack used only to build the tables.
Bitboard sliding_attack(const int* dirs, Square sq, Bitboard occ) {
Bitboard attacks = 0;
for (int i = 0; i < 4; ++i) {
Square prev = sq;
int t = int(sq) + dirs[i];
while (t >= 0 && t < 64 && file_distance(Square(t), prev) <= 1) {
attacks |= square_bb(Square(t));
if (occ & square_bb(Square(t))) break;
prev = Square(t);
t += dirs[i];
}
}
return attacks;
}
// Relevant-occupancy mask: the ray squares excluding board edges.
Bitboard slider_mask(const int* dirs, Square sq) {
Bitboard edges = ((RANK_1_BB | RANK_8_BB) & ~rank_bb(rank_of(sq)))
| ((FILE_A_BB | FILE_H_BB) & ~file_bb(file_of(sq)));
return sliding_attack(dirs, sq, 0) & ~edges;
}
// Deterministic xorshift PRNG (fixed seed -> reproducible magics).
struct PRNG {
uint64_t s;
explicit PRNG(uint64_t seed) : s(seed) {}
uint64_t next() {
s ^= s >> 12; s ^= s << 25; s ^= s >> 27;
return s * 2685821657736338717ULL;
}
// Few set bits -> better magic candidates.
uint64_t sparse() { return next() & next() & next(); }
};
void init_magics(const int* dirs, Magic magics[], Bitboard table[]) {
PRNG rng(0x9E3779B97F4A7C15ull); // fixed seed -> reproducible magics
Bitboard occupancy[4096];
Bitboard reference[4096];
int epoch[4096] = {};
int currentEpoch = 0;
size_t offset = 0;
for (int sq = 0; sq < 64; ++sq) {
Magic& m = magics[sq];
m.mask = slider_mask(dirs, Square(sq));
m.shift = 64 - popcount(m.mask);
m.attacks = table + offset;
// Enumerate every subset of the mask (Carry-Rippler).
Bitboard b = 0;
int size = 0;
do {
occupancy[size] = b;
reference[size] = sliding_attack(dirs, Square(sq), b);
++size;
b = (b - m.mask) & m.mask;
} while (b);
// Search for a magic that maps subsets to indices collision-free
// (collisions are fine only when the attack set is identical).
for (;;) {
Bitboard magic;
do {
magic = rng.sparse();
} while (popcount((m.mask * magic) >> 56) < 6);
m.magic = magic;
++currentEpoch;
bool ok = true;
for (int i = 0; i < size; ++i) {
unsigned idx = m.index(occupancy[i]);
if (epoch[idx] < currentEpoch) {
epoch[idx] = currentEpoch;
m.attacks[idx] = reference[i];
} else if (m.attacks[idx] != reference[i]) {
ok = false;
break;
}
}
if (ok) break;
}
offset += size;
}
}
} // namespace
void init_bitboards() {
for (int s = 0; s < 64; ++s) {
Bitboard b = square_bb(Square(s));
PawnAttacks[WHITE][s] = ((b & ~FILE_H_BB) << 9) | ((b & ~FILE_A_BB) << 7);
PawnAttacks[BLACK][s] = ((b & ~FILE_A_BB) >> 9) | ((b & ~FILE_H_BB) >> 7);
const int knightDirs[8] = { 17, 15, 10, 6, -6, -10, -15, -17 };
Bitboard kn = 0;
for (int d : knightDirs) {
int t = s + d;
if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 2)
kn |= square_bb(Square(t));
}
KnightAttacks[s] = kn;
const int kingDirs[8] = { 8, -8, 1, -1, 9, 7, -7, -9 };
Bitboard kg = 0;
for (int d : kingDirs) {
int t = s + d;
if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 1)
kg |= square_bb(Square(t));
}
KingAttacks[s] = kg;
}
const int rookDirs[4] = { 8, -8, 1, -1 };
const int bishopDirs[4] = { 9, 7, -7, -9 };
init_magics(rookDirs, RookMagics, RookTable);
init_magics(bishopDirs, BishopMagics, BishopTable);
}
} // namespace chess
+87
View File
@@ -0,0 +1,87 @@
// Bitboard utilities and precomputed attack tables. Sliders use magic
// bitboards; the tables are built once by init_bitboards() (called from
// engine_create) and are read-only afterwards.
#ifndef CHESS_BITBOARD_H
#define CHESS_BITBOARD_H
#include "types.h"
#if defined(_MSC_VER)
#include <intrin.h>
#endif
namespace chess {
constexpr Bitboard FILE_A_BB = 0x0101010101010101ULL;
constexpr Bitboard FILE_H_BB = 0x8080808080808080ULL;
constexpr Bitboard RANK_1_BB = 0x00000000000000FFULL;
constexpr Bitboard RANK_8_BB = 0xFF00000000000000ULL;
inline Bitboard square_bb(Square s) { return 1ULL << s; }
inline Bitboard file_bb(File f) { return FILE_A_BB << f; }
inline Bitboard rank_bb(Rank r) { return RANK_1_BB << (8 * int(r)); }
inline int popcount(Bitboard b) {
#if defined(_MSC_VER)
return int(__popcnt64(b));
#else
return __builtin_popcountll(b);
#endif
}
inline Square lsb(Bitboard b) {
#if defined(_MSC_VER)
unsigned long i;
_BitScanForward64(&i, b);
return Square(i);
#else
return Square(__builtin_ctzll(b));
#endif
}
// Returns the least-significant square and clears it from b.
inline Square pop_lsb(Bitboard& b) {
Square s = lsb(b);
b &= b - 1;
return s;
}
inline bool more_than_one(Bitboard b) { return b & (b - 1); }
// Precomputed leaper attacks (filled by init_bitboards).
extern Bitboard PawnAttacks[COLOR_NB][SQUARE_NB];
extern Bitboard KnightAttacks[SQUARE_NB];
extern Bitboard KingAttacks[SQUARE_NB];
struct Magic {
Bitboard mask;
Bitboard magic;
Bitboard* attacks;
unsigned shift;
unsigned index(Bitboard occ) const {
return unsigned(((occ & mask) * magic) >> shift);
}
};
extern Magic BishopMagics[SQUARE_NB];
extern Magic RookMagics[SQUARE_NB];
inline Bitboard bishop_attacks(Square s, Bitboard occ) {
const Magic& m = BishopMagics[s];
return m.attacks[m.index(occ)];
}
inline Bitboard rook_attacks(Square s, Bitboard occ) {
const Magic& m = RookMagics[s];
return m.attacks[m.index(occ)];
}
inline Bitboard queen_attacks(Square s, Bitboard occ) {
return bishop_attacks(s, occ) | rook_attacks(s, occ);
}
// Must be called once before any attack query (engine_create does this).
void init_bitboards();
} // namespace chess
#endif // CHESS_BITBOARD_H
+550
View File
@@ -0,0 +1,550 @@
/* chess_engine.cpp - the DLL boundary (extern "C" ABI).
*
* The rules layer (board, move generation, make/unmake, hashing, perft) lives in
* the other src/*.cpp files and is ready to use. engine_best_move is intentionally
* left for YOU: that is where your search/evaluation goes. Everything below the
* FEN-in / UCI-out boundary should stay native — the managed side crosses it once
* per move.
*/
#ifndef CHESS_ENGINE_BUILD
#define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */
#endif
#pragma once
#include "chess_engine.h"
#include "bitboard.h"
#include "zobrist.h"
#include "position.h"
#include "movegen.h"
#include "uci.h"
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <new>
#include <string>
#include <memory>
#include <vector>
/* Search score constants. Scores are side-to-move-relative (negamax): positive is
* good for whoever is to move. MATE_BOUND is the threshold above which a score is a
* "mate in N" rather than a positional eval; INF is the window sentinel (kept above
* MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */
static constexpr int MATE = 200000;
static constexpr int MATE_BOUND = MATE - 1000;
static constexpr int INF = 1000000;
/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored),
* UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */
enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER };
/* One shared, process-wide transposition table backs every game (every engine
* handle), so analysis persists and is reused across games. It is lock-free: each
* slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist
* key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two
* concurrent searches tore the pair, the recovered key won't match and the read is
* treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */
struct TTEntry {
std::atomic<uint64_t> xorKey{0};
std::atomic<uint64_t> data{0};
};
struct TranspositionTable {
std::unique_ptr<TTEntry[]> entries;
size_t mask = 0; /* count - 1; count is a power of two */
};
static TranspositionTable g_tt;
static constexpr size_t TT_MEGABYTES = 256;
/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored
* entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 —
* letting data == 0 mean "empty slot". */
static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) {
return static_cast<uint64_t>(static_cast<uint32_t>(score))
| (static_cast<uint64_t>(move.data) << 32)
| (static_cast<uint64_t>(static_cast<uint8_t>(depth)) << 48)
| (static_cast<uint64_t>(static_cast<uint8_t>(bound)) << 56);
}
static int tt_score(uint64_t d) { return static_cast<int32_t>(static_cast<uint32_t>(d & 0xFFFFFFFFu)); }
static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_t>(d >> 32)); }
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
* shared g_tt above. */
struct ChessEngine {
int skill = 20; /* 1..20 from the UI; controls search depth */
};
static int copy_out(const char* src, char* out_buf, int out_len) {
if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER;
const size_t need = std::strlen(src) + 1; /* + NUL */
if (need > static_cast<size_t>(out_len)) return CHESS_ERR_BUFFER;
std::memcpy(out_buf, src, need);
return CHESS_OK;
}
/* Attack tables and Zobrist keys are global and read-only after this runs. */
static void ensure_initialized() {
static bool done = false;
if (done) return;
chess::init_bitboards();
chess::Zobrist::init();
done = true;
}
/* Pulls "skill=N" out of the engine_create options string; clamps to the UI's 1..20. */
static int parse_skill(const char* options, int fallback) {
if (!options) return fallback;
const char* p = std::strstr(options, "skill=");
if (!p) return fallback;
int v = std::atoi(p + 6);
return v < 1 ? 1 : v > 20 ? 20 : v;
}
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
static int depth_for_skill(int skill) {
return skill; /* skill N -> N plies */
}
static size_t floor_pow2(size_t n) {
size_t p = 1;
while ((p << 1) != 0 && (p << 1) <= n) p <<= 1;
return p;
}
/* Allocate the shared table exactly once, to the largest power-of-two entry count that
* fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe:
* call_once guards the first concurrent engine_create. Entries start zeroed (empty). */
static void ensure_tt() {
static std::once_flag once;
std::call_once(once, [] {
size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry));
if (count < 1) count = 1;
g_tt.entries = std::make_unique<TTEntry[]>(count);
g_tt.mask = count - 1;
});
}
/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four
* center squares (d4/e4/d5/e5): 2.0 dead center, 0.5 in a corner, scaling linearly.
* Multiply a piece's base value by this to reward central placement. */
static double center_multiplier(chess::Square s) {
/* |2*coord - 7| is the distance from center in half-squares: 1 (center) .. 7 (edge). */
int fileDist = std::abs(2 * int(chess::file_of(s)) - 7);
int rankDist = std::abs(2 * int(chess::rank_of(s)) - 7);
int dist = fileDist > rankDist ? fileDist : rankDist; /* Chebyshev distance, 1 .. 7 */
return dist * 20; /* 1 -> 2.0, 7 -> 0.5 */
}
static int piece_mobility(const chess::Position& pos, chess::Square s, chess::Piece pc, chess::Color c) {
chess::Bitboard occ = pos.pieces();
chess::Bitboard targets;
switch (chess::type_of(pc)) {
case chess::KNIGHT: targets = chess::KnightAttacks[s]; break;
case chess::BISHOP: targets = chess::bishop_attacks(s, occ); break;
case chess::ROOK: targets = chess::rook_attacks(s, occ); break;
case chess::QUEEN: targets = chess::queen_attacks(s, occ); break;
case chess::KING: targets = chess::KingAttacks[s]; break;
default: return 0; // pawns: mobility usually handled via push/attack separately
}
return chess::popcount(targets & ~pos.pieces(c)); // exclude squares blocked by own pieces
}
static chess::Bitboard front_span(chess::Color c, chess::Square s) {
chess::File f = file_of(s);
chess::Bitboard files = file_bb(f);
if (f > chess::FILE_A) files |= chess::file_bb(chess::File(f - 1));
if (f < chess::FILE_H) files |= chess::file_bb(chess::File(f + 1));
// Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts
// are always in [8,56] — no shift-by-64 UB to guard against.
chess::Rank r = rank_of(s);
chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r
: ((1ULL << (8 * r)) - 1); // ranks < r
return files & ahead;
}
static chess::Bitboard front_span_file_only(chess::Color c, chess::Square s) {
chess::File f = file_of(s);
chess::Bitboard files = file_bb(f);
// Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts
// are always in [8,56] — no shift-by-64 UB to guard against.
chess::Rank r = rank_of(s);
chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r
: ((1ULL << (8 * r)) - 1); // ranks < r
return files & ahead;
}
static int evaluatePawn(const chess::Position& pos, const chess::Color c, const chess::Square s) {
chess::Bitboard span = front_span(c, s);
chess::Bitboard file_span = front_span_file_only(c, s);
chess::Rank r = rank_of(s);
int squaresToPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);;
bool isPassed = !(span & pos.pieces(~c, chess::PAWN));
bool isBlocked = (file_span & pos.pieces(c, chess::PAWN)) | (file_span & pos.pieces(~c, chess::PAWN));
bool isDoubled = (file_span & pos.pieces(c, chess::PAWN));
int score = 100;
if (isPassed && !isBlocked)
score += (6 - squaresToPromotion) * 100; // Bonus for passed pawns, more as they get closer to promotion
if (isDoubled)
score -= 20; // Penalty for doubled pawns
if (isBlocked)
score -= 20; // Penalty for blocked pawns
return score;
}
static int castleIncentive(const chess::Position& pos, chess::Color c) {
if (!pos.pieces(chess::QUEEN))
return 0;
chess::Square k = pos.king_square(c);
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
: (k == chess::G8 || k == chess::C8);
return castled ? 600 : 0;
}
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
int score = 0;
switch (chess::type_of(pc)) {
case chess::PAWN: score = evaluatePawn(pos, c, s); break;
case chess::KNIGHT: score = 320; break;
case chess::BISHOP: score = 330; break;
case chess::ROOK: score = 500; break;
case chess::QUEEN: score = 900; break;
case chess::KING: score = castleIncentive(pos, c); break;
default: return 0;
}
score += center_multiplier(s);
if (pc != chess::B_PAWN && pc != chess::W_PAWN)
score += piece_mobility(pos, s, pc, c) * 25;
return score;
}
static int evaluate(const chess::Position& pos) {
int score = 0;
chess::Bitboard white = pos.pieces(chess::WHITE);
while (white) {
chess::Square s = chess::pop_lsb(white);
chess::Piece pc = pos.piece_on(s);
chess::Color c = chess::color_of(pc);
score += evaluatePiece(pos, s, pc, c);
}
chess::Bitboard black = pos.pieces(chess::BLACK);
while (black) {
chess::Square s = chess::pop_lsb(black);
chess::Piece pc = pos.piece_on(s);
chess::Color c = chess::color_of(pc);
score -= evaluatePiece(pos, s, pc, c);
}
return score;
}
/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
* move, so flip the sign when black is to move. */
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
int s = evaluate(pos);
return whiteToMove ? s : -s;
}
/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the
* probing node's ply when crossing the TT (store adds ply, retrieve subtracts it).
* Non-mate scores pass through untouched. */
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
static int piece_value(chess::PieceType pt) {
switch (pt) {
case chess::PAWN: return 100;
case chess::KNIGHT: return 320;
case chess::BISHOP: return 330;
case chess::ROOK: return 500;
case chess::QUEEN: return 900;
default: return 0;
}
}
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
* cut a sibling), then the remaining quiet moves. `killers` points at this ply's two-entry
* slot; `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */
static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove,
const chess::Move* killers, bool scoreChecks) {
if (m == ttMove)
return 2000000; /* dwarfs any capture/killer/check score below */
int score = 0;
if (scoreChecks && pos.gives_check(m))
score += 1000;
chess::Piece victim = pos.piece_on(m.to());
#ifdef BENCH_DISABLE_KILLERS
/* Benchmark A/B only (defined by bench.ps1): the pre-killer ordering — captures by
* MVV-LVA above quiet moves, no killer band — so the script can time the killer speedup. */
(void)killers;
if (victim != chess::NO_PIECE)
score += 100 + 10 * piece_value(chess::type_of(victim))
- piece_value(chess::type_of(pos.piece_on(m.from())));
else if (m.type() == chess::EN_PASSANT)
score += 100 + 10 * piece_value(chess::PAWN);
#else
if (victim != chess::NO_PIECE)
score += 100000 + 10 * piece_value(chess::type_of(victim))
- piece_value(chess::type_of(pos.piece_on(m.from())));
else if (m.type() == chess::EN_PASSANT)
score += 100000 + 10 * piece_value(chess::PAWN);
else if (m == killers[0])
score += 90000; /* quiet move that beta-cut a sibling at this ply */
else if (m == killers[1])
score += 80000;
#endif
return score;
}
/* Sort the move list in place, best-scoring first. Scores are computed once up
* front so gives_check isn't re-evaluated on every comparison. ttMove may be
* MOVE_NONE, in which case no move matches it and ordering falls back to captures. */
static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove,
const chess::Move* killers, bool scoreChecks) {
struct ScoredMove { int score; chess::Move move; };
ScoredMove scored[256];
for (int i = 0; i < moves.size(); i++)
scored[i] = { order_score(pos, moves.moves[i], ttMove, killers, scoreChecks), moves.moves[i] };
std::sort(scored, scored + moves.size(),
[](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; });
for (int i = 0; i < moves.size(); i++)
moves.moves[i] = scored[i].move;
}
extern "C" {
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
ensure_initialized();
ensure_tt();
auto* e = new (std::nothrow) ChessEngine();
if (!e) return nullptr;
e->skill = parse_skill(options, e->skill);
return e;
}
CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
const char* /*name*/,
const char* /*value*/) {
if (!engine) return CHESS_ERR_NULL_HANDLE;
return CHESS_OK; /* TODO: store options */
}
/* Per-search scratch, threaded through the recursion. Kept off global scope so two engine
* handles can search concurrently without sharing node counts or killer tables. killers[ply]
* holds up to two quiet moves that recently caused a beta cutoff at that ply; trying them
* early (right after captures) prunes far more — the quiet-move ordering the search otherwise
* lacks. */
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
struct SearchContext {
uint64_t nodes = 0;
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
};
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
* bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is
* distance from the root (mate scoring only). Scores are side-to-move-relative.
* Fail-soft: returns the true best found even outside [alpha, beta]. */
static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
int alpha, int beta, bool whiteToMove, SearchContext& ctx) {
ctx.nodes++;
/* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition
* history, so this must come before both the leaf eval and any TT probe. */
if (ply > 0 && pos.is_draw())
return 0;
if (depth <= 0)
return evaluate_stm(pos, whiteToMove);
const uint64_t key = pos.key();
TTEntry& slot = g_tt.entries[key & g_tt.mask];
const uint64_t data = slot.data.load(std::memory_order_relaxed);
const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed);
chess::Move ttMove = chess::MOVE_NONE;
if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */
ttMove = tt_move(data); /* always reusable for ordering */
int edepth = tt_depth(data);
Bound b = tt_bound(data);
/* Trust the score only if it was searched deep enough for this node AND no deeper
* than this bot's own strength — so a weak bot can't borrow a stronger game's
* deeper analysis (it still gets the move for ordering, which can't leak strength). */
if (edepth >= depth && edepth <= maxDepth) {
int s = score_from_tt(tt_score(data), ply);
if (b == Bound::EXACT) return s;
if (b == Bound::LOWER && s >= beta) return s;
if (b == Bound::UPPER && s <= alpha) return s;
}
}
chess::MoveList moves;
pos.generate_legal(moves);
if (moves.size() == 0)
return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */
order_moves(pos, moves, ttMove, ctx.killers[ply], depth <= 2);
const int alphaOrig = alpha;
int best = -INF;
chess::Move bestMove = chess::MOVE_NONE;
for (int i = 0; i < moves.size(); i++) {
chess::Move move = moves.moves[i];
pos.do_move(move);
int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, ctx);
pos.undo_move(move);
if (score > best) {
best = score;
bestMove = move;
}
if (best > alpha)
alpha = best;
if (best >= beta) {
/* A quiet move good enough to fail high here is a strong candidate in sibling
* lines at this ply — remember it as a killer. pos is back to pre-move state
* after undo_move, so piece_on(to) still flags a capture correctly. */
bool isCapture = pos.piece_on(move.to()) != chess::NO_PIECE
|| move.type() == chess::EN_PASSANT;
if (!isCapture && ply < MAX_PLY && ctx.killers[ply][0] != move) {
ctx.killers[ply][1] = ctx.killers[ply][0];
ctx.killers[ply][0] = move;
}
break; /* fail-high cutoff */
}
}
Bound flag = best <= alphaOrig ? Bound::UPPER
: best >= beta ? Bound::LOWER
: Bound::EXACT;
/* Depth-preferred replacement: keep the deepest analysis of each slot. The stored
* payload is written before the xorKey so any concurrent reader that catches a
* half-update fails the XOR check and treats it as a miss. */
int storedDepth = (data == 0) ? -1 : tt_depth(data);
if (depth >= storedDepth) {
uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag);
slot.data.store(packed, std::memory_order_relaxed);
slot.xorKey.store(key ^ packed, std::memory_order_relaxed);
}
return best;
}
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
const char* fen,
const char* history,
char* out_buf,
int out_len) {
if (!engine) return CHESS_ERR_NULL_HANDLE;
if (!fen || !*fen) return CHESS_ERR_BAD_FEN;
auto held = std::make_unique<chess::Position>(chess::Position::from_fen(fen));
chess::Position& pos = *held;
bool whiteToMove = pos.side_to_move() == chess::WHITE;
/* Seed the prior positions (one FEN per line) so is_draw() sees repetitions and
* the 50-move count that the current FEN alone can't express. */
if (history && *history) {
std::vector<uint64_t> priorKeys;
const char* p = history;
while (*p) {
const char* nl = std::strchr(p, '\n');
size_t len = nl ? static_cast<size_t>(nl - p) : std::strlen(p);
if (len > 0)
priorKeys.push_back(chess::Position::from_fen(std::string(p, len)).key());
if (!nl) break;
p = nl + 1;
}
if (!priorKeys.empty())
pos.seed_history(priorKeys.data(), static_cast<int>(priorKeys.size()));
}
chess::MoveList moves;
pos.generate_legal(moves);
if (moves.size() == 0)
return CHESS_ERR_NO_MOVE;
SearchContext ctx;
int maxDepth = depth_for_skill(engine->skill);
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
/* Iterative deepening: each depth seeds the next depth's move ordering (via the
* previous best move and the TT it filled), which makes the deeper search prune
* far harder than searching to maxDepth cold. */
for (int d = 1; d <= maxDepth; d++) {
int alpha = -INF, beta = INF;
chess::Move iterBest = bestMove;
int iterScore = -INF;
order_moves(pos, moves, iterBest, ctx.killers[0], true);
for (int i = 0; i < moves.size(); i++) {
chess::Move move = moves.moves[i];
pos.do_move(move);
int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, ctx);
pos.undo_move(move);
if (score > iterScore) {
iterScore = score;
iterBest = move;
}
if (score > alpha)
alpha = score;
}
bestMove = iterBest; /* commit only a fully completed iteration */
std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n",
d, static_cast<unsigned long long>(ctx.nodes),
chess::move_to_uci(iterBest).c_str(), iterScore);
}
return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len);
}
CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) {
return copy_out("custom-engine 0.1.0", out_buf, out_len);
}
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) {
delete engine; /* delete nullptr is safe */
}
} /* extern "C" */
+134
View File
@@ -0,0 +1,134 @@
#include "movegen.h"
namespace chess {
namespace {
void add_promotions(MoveList& list, Square from, Square to) {
list.add(Move::make(from, to, PROMOTION, QUEEN));
list.add(Move::make(from, to, PROMOTION, ROOK));
list.add(Move::make(from, to, PROMOTION, BISHOP));
list.add(Move::make(from, to, PROMOTION, KNIGHT));
}
void generate_castling(const Position& pos, MoveList& list) {
if (pos.in_check()) return;
Color us = pos.side_to_move(), them = ~us;
Bitboard occ = pos.pieces();
auto attacked = [&](Square sq) { return (pos.attackers_to(sq) & pos.pieces(them)) != 0; };
if (us == WHITE) {
if (pos.can_castle(WHITE, KINGSIDE) &&
!(occ & (square_bb(F1) | square_bb(G1))) && !attacked(F1) && !attacked(G1))
list.add(Move::make(E1, G1, CASTLING));
if (pos.can_castle(WHITE, QUEENSIDE) &&
!(occ & (square_bb(B1) | square_bb(C1) | square_bb(D1))) && !attacked(D1) && !attacked(C1))
list.add(Move::make(E1, C1, CASTLING));
} else {
if (pos.can_castle(BLACK, KINGSIDE) &&
!(occ & (square_bb(F8) | square_bb(G8))) && !attacked(F8) && !attacked(G8))
list.add(Move::make(E8, G8, CASTLING));
if (pos.can_castle(BLACK, QUEENSIDE) &&
!(occ & (square_bb(B8) | square_bb(C8) | square_bb(D8))) && !attacked(D8) && !attacked(C8))
list.add(Move::make(E8, C8, CASTLING));
}
}
} // namespace
void generate_pseudo(const Position& pos, MoveList& list) {
Color us = pos.side_to_move(), them = ~us;
Bitboard occ = pos.pieces();
Bitboard targets = ~pos.pieces(us); // empty squares or enemy pieces
Bitboard theirs = pos.pieces(them);
// Pawns
int push = (us == WHITE) ? 8 : -8;
Rank promoRank = (us == WHITE) ? RANK_8 : RANK_1;
Rank startRank = (us == WHITE) ? RANK_2 : RANK_7;
Bitboard b = pos.pieces(us, PAWN);
while (b) {
Square s = pop_lsb(b);
Square t = Square(int(s) + push);
if (!(occ & square_bb(t))) {
if (rank_of(t) == promoRank) {
add_promotions(list, s, t);
} else {
list.add(Move::make(s, t));
if (rank_of(s) == startRank) {
Square t2 = Square(int(t) + push);
if (!(occ & square_bb(t2))) list.add(Move::make(s, t2));
}
}
}
Bitboard caps = PawnAttacks[us][s] & theirs;
while (caps) {
Square c = pop_lsb(caps);
if (rank_of(c) == promoRank) add_promotions(list, s, c);
else list.add(Move::make(s, c));
}
if (pos.ep_square() != SQ_NONE && (PawnAttacks[us][s] & square_bb(pos.ep_square())))
list.add(Move::make(s, pos.ep_square(), EN_PASSANT));
}
// Knights
b = pos.pieces(us, KNIGHT);
while (b) {
Square s = pop_lsb(b);
Bitboard a = KnightAttacks[s] & targets;
while (a) list.add(Move::make(s, pop_lsb(a)));
}
// Bishops
b = pos.pieces(us, BISHOP);
while (b) {
Square s = pop_lsb(b);
Bitboard a = bishop_attacks(s, occ) & targets;
while (a) list.add(Move::make(s, pop_lsb(a)));
}
// Rooks
b = pos.pieces(us, ROOK);
while (b) {
Square s = pop_lsb(b);
Bitboard a = rook_attacks(s, occ) & targets;
while (a) list.add(Move::make(s, pop_lsb(a)));
}
// Queens
b = pos.pieces(us, QUEEN);
while (b) {
Square s = pop_lsb(b);
Bitboard a = queen_attacks(s, occ) & targets;
while (a) list.add(Move::make(s, pop_lsb(a)));
}
// King (non-castling)
{
Square s = pos.king_square(us);
Bitboard a = KingAttacks[s] & targets;
while (a) list.add(Move::make(s, pop_lsb(a)));
}
}
void Position::generate_legal(MoveList& list) {
list.count = 0;
MoveList pseudo;
generate_pseudo(*this, pseudo);
Color us = sideToMove;
for (Move m : pseudo) {
do_move(m);
// After do_move, sideToMove is the opponent; the move is legal iff the
// side that just moved did not leave its own king attacked.
bool legal = (attackers_to(king_square(us)) & pieces(sideToMove)) == 0;
undo_move(m);
if (legal) list.add(m);
}
generate_castling(*this, list); // already fully legal
}
} // namespace chess

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