Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5501c84e9b | ||
|
|
d78251c928 | ||
|
|
94054c0f1b | ||
|
|
183a53234f | ||
|
|
f956f4bffa | ||
|
|
a53b0c2e43 | ||
|
|
edc4d70678 | ||
|
|
3bfcbaa190 | ||
|
|
59b318b75c | ||
|
|
e5e0c9cc48 | ||
|
|
3947bd0daf | ||
|
|
b99525ae51 | ||
|
|
9da7b1986b | ||
|
|
a0b8c2602a | ||
|
|
7604798e68 | ||
|
|
02163a4fc4 | ||
|
|
1680d13b05 | ||
|
|
a1e2ad9c2d | ||
|
|
26b4c0e59b | ||
|
|
aeef320cb4 | ||
|
|
d71e485611 | ||
|
|
d09a067377 | ||
|
|
acbee436bb | ||
|
|
d452ee414b | ||
|
|
f09ca60315 | ||
|
|
743c395e11 | ||
|
|
62e146648c | ||
|
|
6402ef268c | ||
|
|
c92c46e6d4 | ||
|
|
e8a3bfe432 | ||
|
|
8b78fd589c | ||
|
|
be07f7504e | ||
|
|
7bfbae7301 | ||
|
|
b651683e63 | ||
|
|
d87cea572f | ||
|
|
5345427e0c | ||
|
|
76b054ee9d | ||
|
|
2db95cec34 | ||
|
|
9b69489c4e | ||
|
|
fc2a2e122b | ||
|
|
c3280aa6d3 | ||
|
|
d2ae34f530 | ||
|
|
52d468a46e | ||
|
|
7fcfb270a1 | ||
|
|
ea60ee5a4b | ||
|
|
3dc1bc452e | ||
|
|
99a9474ef7 | ||
|
|
4884c32fc8 | ||
|
|
8043eee4cc | ||
|
|
5fd3a5d7c1 | ||
|
|
9969c6e3cf | ||
|
|
0788015cb0 | ||
|
|
34de3c1abc | ||
|
|
311d62a719 | ||
|
|
4217bf3703 | ||
|
|
fd68ada82e | ||
|
|
7c77cd0e52 | ||
|
|
93bdb3518a | ||
|
|
69e1e8450d | ||
|
|
09b408a75b | ||
|
|
e74de40d2e | ||
|
|
03dd308449 | ||
|
|
54e4f25be7 | ||
|
|
c2622132bc | ||
|
|
9bd7eb283e | ||
|
|
9b86bfafe0 | ||
|
|
95b4ae63ea | ||
|
|
edab13f8e4 | ||
|
|
ea78302041 | ||
|
|
3da59f9e93 | ||
|
|
4bc69d1989 | ||
|
|
c98a78d642 | ||
|
|
b6990a30b7 | ||
|
|
86888b21d2 | ||
|
|
140fbbfa76 | ||
|
|
a176e29388 | ||
|
|
b425c4d5d6 | ||
|
|
3b91055666 | ||
|
|
a3b384b221 | ||
|
|
eb3d15527a | ||
|
|
636f133288 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet test:*)",
|
||||
"Bash(dotnet build)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(dotnet build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
|
||||
# compiled libchess_engine.so loads there. Keep this == the server's release.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,9 +26,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x' # adjust if needed
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||
|
||||
- name: Build native chess engine (libchess_engine.so)
|
||||
run: |
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build
|
||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -o ./publish
|
||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -45,6 +54,12 @@ jobs:
|
||||
name: site-publish
|
||||
path: publish
|
||||
|
||||
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||
# carries it to the server, where the service user can run it regardless of file owner.
|
||||
- name: Restore Stockfish executable bit
|
||||
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
@@ -54,7 +69,9 @@ jobs:
|
||||
|
||||
- name: Rsync to server
|
||||
run: |
|
||||
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||
|
||||
- name: Reload and restart service
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net.slnf
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||
|
||||
@@ -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
|
||||
+7
-1
@@ -3,6 +3,9 @@
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# Learned chess engine weights (runtime training output, not source)
|
||||
chess-data/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
@@ -360,4 +363,7 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.Playwright;
|
||||
using Microsoft.Playwright.NUnit;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace JoshHeaps.Net.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the acoustic-ranging pipeline against a simulated room. The DSP runs in the browser
|
||||
/// because that is where it runs in production; these tests drive the same files the page loads,
|
||||
/// so there is no second implementation to drift.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EchoDspTests : PageTest
|
||||
{
|
||||
private TestConfiguration Config => TestConfiguration.Instance;
|
||||
|
||||
public override BrowserNewContextOptions ContextOptions() => Config.GetBrowserContextOptions();
|
||||
|
||||
[SetUp]
|
||||
public async Task LoadPipeline()
|
||||
{
|
||||
await Page.GotoAsync(Config.Test.BaseUrl);
|
||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoDsp.js" });
|
||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoSim.js" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matched_Filter_Finds_The_Chirp_Within_One_Sample()
|
||||
{
|
||||
var error = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
||||
const recording = new Float32Array(sampleRate);
|
||||
for (let i = 0; i < recording.length; i++) recording[i] = (Math.random() * 2 - 1) * 0.02;
|
||||
|
||||
const offset = 12345;
|
||||
for (let i = 0; i < chirp.length; i++) recording[offset + i] += chirp[i] * 0.3;
|
||||
|
||||
const peak = EchoDsp.findFirstPeak(EchoDsp.matchedFilterEnvelope(recording, chirp));
|
||||
return Math.abs(peak.index - offset);
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(error, Is.LessThan(1.0), "arrival should be located to within a sample");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Tone_Cannot_Be_Located_But_A_Chirp_Can()
|
||||
{
|
||||
var ratios = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const sidelobeRatio = template => {
|
||||
const recording = new Float32Array(sampleRate / 2);
|
||||
const offset = 8000;
|
||||
for (let i = 0; i < template.length; i++) recording[offset + i] += template[i];
|
||||
|
||||
const envelope = EchoDsp.matchedFilterEnvelope(recording, template);
|
||||
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length);
|
||||
let highest = 0;
|
||||
for (let i = 0; i < envelope.length; i++) {
|
||||
if (Math.abs(i - peak.index) < 200) continue;
|
||||
highest = Math.max(highest, envelope[i]);
|
||||
}
|
||||
return highest / peak.value;
|
||||
};
|
||||
|
||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
||||
const tone = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 5000, endHz: 5000 });
|
||||
return [sidelobeRatio(chirp), sidelobeRatio(tone)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(ratios[1], Is.GreaterThan(0.5), "a tone should correlate almost as well far from the true arrival");
|
||||
Assert.That(ratios[0], Is.LessThan(0.25), "a chirp should give one unambiguous arrival");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clock_Offset_And_Pipeline_Latency_Cancel()
|
||||
{
|
||||
var distance = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const flight = (4.2 / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
const offsetB = 987654;
|
||||
const latencyA = 0.031 * sampleRate;
|
||||
const latencyB = 0.128 * sampleRate;
|
||||
|
||||
return EchoDsp.pairDistance({
|
||||
a1: latencyA,
|
||||
a2: slot + latencyB + flight,
|
||||
b1: offsetB + latencyA + flight,
|
||||
b2: offsetB + slot + latencyB,
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
});
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distance, Is.EqualTo(4.2).Within(0.001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Collocated_Devices_Read_Zero_Before_Calibration()
|
||||
{
|
||||
var distance = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const spacing = (0.19 / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
const latencyA = 0.04 * sampleRate;
|
||||
const latencyB = 0.11 * sampleRate;
|
||||
|
||||
// Two tabs on one machine: one speaker, one microphone, so the self path and the
|
||||
// cross path are the same physical distance.
|
||||
return EchoDsp.pairDistance({
|
||||
a1: latencyA + spacing,
|
||||
a2: slot + latencyB + spacing,
|
||||
b1: latencyA + spacing,
|
||||
b2: slot + latencyB + spacing,
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
});
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distance, Is.EqualTo(0).Within(0.001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Speaker_To_Microphone_Spacing_Is_Added_Back()
|
||||
{
|
||||
var distances = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const sampleRate = 48000;
|
||||
const speedOfSound = 343;
|
||||
const truth = 3.0;
|
||||
const epsilonA = 0.18;
|
||||
const epsilonB = 0.04;
|
||||
const samples = metres => (metres / speedOfSound) * sampleRate;
|
||||
const slot = 0.4 * sampleRate;
|
||||
|
||||
const peaks = {
|
||||
a1: samples(epsilonA),
|
||||
a2: slot + samples(truth),
|
||||
b1: samples(truth),
|
||||
b2: slot + samples(epsilonB),
|
||||
sampleRate,
|
||||
speedOfSound
|
||||
};
|
||||
|
||||
return [
|
||||
EchoDsp.pairDistance(peaks),
|
||||
EchoDsp.pairDistance({ ...peaks, epsilonA, epsilonB })
|
||||
];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(distances[0], Is.EqualTo(3.0 - 0.11).Within(0.005), "uncorrected range reads short");
|
||||
Assert.That(distances[1], Is.EqualTo(3.0).Within(0.005), "correcting for spacing recovers the true range");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Simulated_Room_Recovers_Distances_And_Layout()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const result = EchoSim.runRound({
|
||||
positions: [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]]
|
||||
});
|
||||
return [EchoSim.worstDistanceError(result), EchoSim.worstPositionError(result), result.keep.length];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[2], Is.EqualTo(5), "every device should survive a clean round");
|
||||
Assert.That(errors[0], Is.LessThan(0.05), "worst pairwise range error");
|
||||
Assert.That(errors[1], Is.LessThan(0.15), "worst recovered position error");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Reflection_Louder_Than_The_Direct_Path_Does_Not_Win()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const measure = relativeThreshold => EchoSim.worstDistanceError(EchoSim.runRound({
|
||||
positions: [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]],
|
||||
reflections: [{ extraMetres: 1.8, gain: 5 }],
|
||||
peakOptions: { relativeThreshold }
|
||||
}));
|
||||
|
||||
return [measure(undefined), measure(0.5)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(0.05), "the first arrival is the distance, not the loudest one");
|
||||
Assert.That(errors[1], Is.GreaterThan(1.5),
|
||||
"a threshold high enough to miss the direct path must measure the reflection instead — this is what the default guards against");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Echoes_Inside_The_Correlation_Lobe_Bound_The_Accuracy()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]];
|
||||
const worst = reflectionExtraRange =>
|
||||
EchoSim.worstDistanceError(EchoSim.runRound({ positions, reflectionExtraRange }));
|
||||
|
||||
return [worst([0.4, 4.0]), worst([0.08, 0.4])];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(0.01), "echoes well clear of the direct arrival are rejected outright");
|
||||
Assert.That(errors[1], Is.LessThan(0.15),
|
||||
"echoes arriving inside the correlation lobe cannot be separated and bias the range — this bounds what a device resting on a hard surface can achieve");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Bad_Measurement_Is_Rejected_And_The_Layout_Survives()
|
||||
{
|
||||
var outcome = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.6, 1.3]];
|
||||
const config = EchoSim.buildConfiguration({ positions });
|
||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
||||
|
||||
reports[3].peaks[0] += 9000;
|
||||
|
||||
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
|
||||
const truth = solved.keep.map(index => positions[index]);
|
||||
const aligned = EchoDsp.alignToReference(solved.points, truth);
|
||||
const worst = Math.max(...aligned.map((point, i) => EchoSim.separation(point, truth[i])));
|
||||
const brokenPairSurvived = solved.keep.includes(0) && solved.keep.includes(3);
|
||||
|
||||
return [solved.keep.length, brokenPairSurvived ? 1 : 0, worst];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(outcome[0], Is.EqualTo(4), "exactly one endpoint of the bad pair should be dropped");
|
||||
Assert.That(outcome[1], Is.EqualTo(0), "the impossible pair must not survive");
|
||||
Assert.That(outcome[2], Is.LessThan(0.2), "the remaining layout should be unpoisoned");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Alignment_Undoes_An_Arbitrary_Rotation_And_Mirror()
|
||||
{
|
||||
var errors = await Page.EvaluateAsync<double[]>("""
|
||||
() => {
|
||||
const reference = [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]];
|
||||
const scramble = (points, angle, mirror) => points.map(([x, y]) => {
|
||||
const mx = x * mirror;
|
||||
return [mx * Math.cos(angle) - y * Math.sin(angle) + 11, mx * Math.sin(angle) + y * Math.cos(angle) - 4];
|
||||
});
|
||||
|
||||
const worst = mirror => {
|
||||
const aligned = EchoDsp.alignToReference(scramble(reference, 0.9, mirror), reference);
|
||||
return Math.max(...aligned.map((point, i) => EchoSim.separation(point, reference[i])));
|
||||
};
|
||||
|
||||
return [worst(1), worst(-1)];
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(errors[0], Is.LessThan(1e-9), "rotation and translation should be recovered exactly");
|
||||
Assert.That(errors[1], Is.LessThan(1e-9), "a mirrored solve should be un-mirrored onto the reference");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Consecutive_Frames_Do_Not_Rotate_Or_Flip()
|
||||
{
|
||||
var drift = await Page.EvaluateAsync<double>("""
|
||||
() => {
|
||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9]];
|
||||
const first = EchoSim.runRound({ positions, seed: 11 });
|
||||
|
||||
const previous = new Array(positions.length).fill(null);
|
||||
first.keep.forEach((device, i) => { previous[device] = first.points[i]; });
|
||||
|
||||
const config = EchoSim.buildConfiguration({ positions, seed: 22 });
|
||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
||||
const second = EchoDsp.solveRound(reports, {
|
||||
speedOfSound: config.speedOfSound,
|
||||
previousPoints: previous
|
||||
});
|
||||
|
||||
return Math.max(...second.keep.map((device, i) => EchoSim.separation(second.points[i], previous[device])));
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(drift, Is.LessThan(0.3), "a stationary room should not move between frames");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.Playwright;
|
||||
using Microsoft.Playwright.NUnit;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace JoshHeaps.Net.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
|
||||
/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
|
||||
/// known in advance — this is everything except the acoustics.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EchoRoomTests : PlaywrightTest
|
||||
{
|
||||
private const double TargetMetres = 2.5;
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
private readonly List<IBrowserContext> _contexts = [];
|
||||
private TestConfiguration Config => TestConfiguration.Instance;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task LaunchBrowser()
|
||||
{
|
||||
// Its own Playwright instance and browser: the fake-media launch flags have to be set at
|
||||
// launch time, and the fixture-managed browser is already running by the time tests start.
|
||||
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args =
|
||||
[
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--autoplay-policy=no-user-gesture-required"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public async Task CloseBrowser()
|
||||
{
|
||||
if (_browser is not null) await _browser.CloseAsync();
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task CloseContexts()
|
||||
{
|
||||
foreach (var context in _contexts) await context.CloseAsync();
|
||||
_contexts.Clear();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Two_Devices_Measure_The_Distance_Between_Them()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone");
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
|
||||
var measured = await WaitForMeasurementAsync(first);
|
||||
var alsoMeasured = await WaitForMeasurementAsync(second);
|
||||
|
||||
Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
|
||||
Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone");
|
||||
|
||||
await WaitForMeasurementAsync(first);
|
||||
await second.ClickAsync("#echoLeave");
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
|
||||
await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
|
||||
|
||||
await second.ClickAsync("#echoJoin");
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
|
||||
/// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
|
||||
/// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
|
||||
/// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
|
||||
{
|
||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
||||
var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
|
||||
|
||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
||||
await first.EvaluateAsync("() => { window.__seen = []; }");
|
||||
await first.EvaluateAsync("""
|
||||
() => {
|
||||
const original = EchoPage.renderSolved.bind(EchoPage);
|
||||
EchoPage.renderSolved = update => {
|
||||
const range = update.solved?.matrix?.[0]?.[1];
|
||||
if (range != null) window.__seen.push(range);
|
||||
return original(update);
|
||||
};
|
||||
}
|
||||
""");
|
||||
|
||||
await first.WaitForTimeoutAsync(20000);
|
||||
var seen = await first.EvaluateAsync<double[]>("() => window.__seen");
|
||||
var satOut = await second.EvaluateAsync<int>("() => EchoSession.skippedRounds");
|
||||
|
||||
TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
|
||||
Assert.That(satOut, Is.GreaterThan(0),
|
||||
"the stall must actually have cost the device some slots, or this test proves nothing");
|
||||
Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
|
||||
Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
|
||||
"every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
|
||||
{
|
||||
var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
|
||||
|
||||
await page.WaitForFunctionAsync(
|
||||
"() => EchoAudio.highestFrame > 48000",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
|
||||
|
||||
var capture = await page.EvaluateAsync<double[]>("""
|
||||
() => [
|
||||
EchoAudio.context.sampleRate,
|
||||
EchoAudio.warnings.length,
|
||||
EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
|
||||
EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
|
||||
Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
|
||||
]
|
||||
""");
|
||||
|
||||
Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
|
||||
Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
|
||||
Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
|
||||
Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
|
||||
Assert.That(capture[4], Is.LessThan(48000),
|
||||
"the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
|
||||
/// is placed where a room of this geometry would put it, including a different unknown output
|
||||
/// latency per slot so the cancellation is actually exercised.
|
||||
/// </summary>
|
||||
private async Task<IPage> NewDeviceAsync(
|
||||
string roomCode,
|
||||
string name,
|
||||
bool fakeMicrophone = true,
|
||||
int stallMilliseconds = 0)
|
||||
{
|
||||
var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
IgnoreHTTPSErrors = true,
|
||||
Permissions = ["microphone"]
|
||||
});
|
||||
|
||||
_contexts.Add(context);
|
||||
var page = await context.NewPageAsync();
|
||||
page.Console += (_, message) =>
|
||||
{
|
||||
if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
|
||||
};
|
||||
|
||||
await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
|
||||
await page.FillAsync("#echoName", name);
|
||||
if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
|
||||
if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
|
||||
await page.ClickAsync("#echoJoin");
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
|
||||
private const string StallScript = """
|
||||
stallMs => {
|
||||
const original = EchoSession.handleRoundStarting.bind(EchoSession);
|
||||
EchoSession.handleRoundStarting = schedule => {
|
||||
const until = performance.now() + stallMs;
|
||||
while (performance.now() < until) { /* hold the thread */ }
|
||||
return original(schedule);
|
||||
};
|
||||
}
|
||||
""";
|
||||
|
||||
private static async Task<double> WaitForMeasurementAsync(IPage page)
|
||||
{
|
||||
await page.WaitForFunctionAsync(
|
||||
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
|
||||
null,
|
||||
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
|
||||
|
||||
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesizes what the microphone would have heard for this round. The device's own chirp is
|
||||
/// placed at the frame it was actually scheduled for rather than at its nominal slot position,
|
||||
/// so any drift between "when the round said to play" and "when playback was really booked"
|
||||
/// reaches the detector instead of being papered over by the harness.
|
||||
/// </summary>
|
||||
private const string FakeMicrophoneScript = """
|
||||
targetMetres => {
|
||||
const speedOfSound = 343;
|
||||
const epsilonMetres = 0.08;
|
||||
const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
|
||||
|
||||
EchoAudio.read = (startFrame, length) => {
|
||||
const round = EchoSession.pending ?? EchoSession.lastRound;
|
||||
if (!round) return null;
|
||||
|
||||
const chirp = EchoSession.chirp;
|
||||
const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
|
||||
const recording = new Float32Array(length);
|
||||
|
||||
round.schedule.slotOrder.forEach((_, slot) => {
|
||||
const own = slot === round.ownSlot;
|
||||
const origin = own
|
||||
? round.scheduledFrame - round.windowStart
|
||||
: round.leadInSamples + slot * round.slotSamples;
|
||||
const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
|
||||
const amplitude = own ? 1.0 : 0.25;
|
||||
|
||||
for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
|
||||
});
|
||||
|
||||
for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
|
||||
return recording;
|
||||
};
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1,26 +1,60 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34728.123
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.6.11822.322 stable
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
|
||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "JoshHeaps.Net.sln",
|
||||
"projects": [
|
||||
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
||||
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: My First Post
|
||||
date: 2026-03-06
|
||||
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
|
||||
tags: [dev, personal]
|
||||
---
|
||||
|
||||
# Welcome to My Blog
|
||||
|
||||
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
|
||||
|
||||
## What to Expect
|
||||
|
||||
I plan to write about:
|
||||
|
||||
- **Projects** I'm working on, like this website and the other things on my portfolio
|
||||
- **Software development** tips and patterns I find useful
|
||||
- **Problem solving** approaches that have helped me grow as a developer
|
||||
|
||||
## Why a Blog?
|
||||
|
||||
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
|
||||
|
||||
Stay tuned for more posts!
|
||||
@@ -0,0 +1,20 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpPost("invalidate")]
|
||||
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
|
||||
{
|
||||
var expectedKey = configuration["BlogApi:InvalidateKey"];
|
||||
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
|
||||
return Unauthorized();
|
||||
|
||||
blogService.ClearCache();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,73 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase
|
||||
public class ChessController(
|
||||
IChessService chessService,
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore,
|
||||
ISelfPlayCoordinator selfPlay,
|
||||
AutoTrainingSettings autoTraining,
|
||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Store of ongoing games.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new chess game and store it in-memory.
|
||||
/// </summary>
|
||||
[HttpGet("new")]
|
||||
[HttpGet("new/{difficulty}")]
|
||||
public ActionResult CreateGame(int difficulty = 20)
|
||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
Guid playerId = Guid.NewGuid();
|
||||
Guid computerId = Guid.NewGuid();
|
||||
var isWhite = Random.Shared.Next(2) == 0;
|
||||
var isWhite = color.ToLowerInvariant() switch
|
||||
{
|
||||
"white" => true,
|
||||
"black" => false,
|
||||
_ => Random.Shared.Next(2) == 0,
|
||||
};
|
||||
|
||||
gameState.Computer = new(difficulty);
|
||||
var computer = engineFactory.Create(difficulty);
|
||||
|
||||
if (isWhite)
|
||||
{
|
||||
gameState.WhitePlayerId = playerId;
|
||||
gameState.BlackPlayerId = computerId;
|
||||
gameState.BlackComputer = computer;
|
||||
}
|
||||
else
|
||||
{
|
||||
gameState.WhitePlayerId = computerId;
|
||||
gameState.BlackPlayerId = playerId;
|
||||
gameState.WhiteComputer = computer;
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give user's browser time to connect to signalR and such.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
|
||||
await orchestrator.PlayAsync(gameState);
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -64,6 +77,55 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
||||
/// chosen independently; when the learned engine plays, the game also trains it.
|
||||
/// </summary>
|
||||
[HttpGet("watch/cpu")]
|
||||
[HttpGet("watch/cpu/{difficulty}")]
|
||||
public ActionResult CreateSelfPlayGame(
|
||||
int difficulty = 4,
|
||||
string whiteEngine = "custom",
|
||||
string blackEngine = "custom",
|
||||
int? whiteSkill = null,
|
||||
int? blackSkill = null)
|
||||
{
|
||||
var config = new SelfPlayConfig(
|
||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||
|
||||
var (gameId, _) = selfPlay.StartGame(config);
|
||||
|
||||
return Ok(new { GameId = gameId });
|
||||
}
|
||||
|
||||
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"stockfish" => ChessEngineKind.Stockfish,
|
||||
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
||||
_ => ChessEngineKind.Custom
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||
/// </summary>
|
||||
[HttpGet("autotrain")]
|
||||
public ActionResult GetAutoTrain() =>
|
||||
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
|
||||
/// <summary>
|
||||
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||
/// </summary>
|
||||
[HttpPost("autotrain")]
|
||||
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||
{
|
||||
autoTraining.GameCount = count; // clamped inside the setter
|
||||
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the "pool" of chess players.
|
||||
/// Test code expects to receive a GUID for the player
|
||||
@@ -73,12 +135,12 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
public ActionResult JoinGame()
|
||||
{
|
||||
Console.WriteLine("joining game");
|
||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
||||
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||
|
||||
if (gameState == null)
|
||||
{
|
||||
gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
gameStore.Add(gameState);
|
||||
}
|
||||
|
||||
Guid playerId = Guid.NewGuid();
|
||||
@@ -96,7 +158,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
isWhite = false;
|
||||
}
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -106,51 +168,72 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public ActionResult GetActiveGames()
|
||||
{
|
||||
var activeGames = gameStore.All
|
||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||
.Select(g => new
|
||||
{
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||
BlackEngine = g.BlackEngineKind.ToString(),
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
})
|
||||
.OrderByDescending(g => g.MoveCount);
|
||||
|
||||
return Ok(activeGames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
||||
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
||||
/// </summary>
|
||||
[HttpGet("weights")]
|
||||
public ActionResult GetLearnedWeights()
|
||||
{
|
||||
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
||||
|
||||
var snapshot = weightsStore.Snapshot();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
||||
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
||||
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}")]
|
||||
public ActionResult GetGameState(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var response = new
|
||||
{
|
||||
gameState.GameId,
|
||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
Pieces = gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new {
|
||||
p.Id,
|
||||
p.Type,
|
||||
p.Color,
|
||||
p.Position.Row,
|
||||
p.Position.Col,
|
||||
p.HasMoved
|
||||
}),
|
||||
gameState.MoveHistory
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
return Ok(gameState.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make a move in the specified game.
|
||||
/// Make a move in the specified game.
|
||||
/// The test passes a JSON body with a MoveDto.
|
||||
/// </summary>
|
||||
[HttpPost("move")]
|
||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
||||
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||
{
|
||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
||||
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
// Check if player is authorized to move
|
||||
@@ -158,7 +241,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
|
||||
|
||||
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
|
||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
|
||||
@@ -167,31 +250,80 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
return NotFound("Chess piece Id does not exist");
|
||||
|
||||
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);
|
||||
|
||||
if (!result.Success)
|
||||
return BadRequest(result);
|
||||
|
||||
if (result.IsCheckmate || result.IsStalemate)
|
||||
{
|
||||
// queue game removal
|
||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
else if (gameState.IsVsComputer)
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
else
|
||||
{
|
||||
// increase timeout if play continues.
|
||||
if (gameState.IsVsComputer)
|
||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
||||
else
|
||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
||||
}
|
||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||
var state = gameState.ToDto();
|
||||
|
||||
return Ok(result);
|
||||
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||
// game. The mover also receives this echo but drops it via the version guard,
|
||||
// since it already rendered the same state from this response.
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||
|
||||
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
|
||||
? gameState.WhiteComputer
|
||||
: gameState.BlackComputer;
|
||||
|
||||
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState));
|
||||
|
||||
return Ok(new { result, state });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
|
||||
/// Used when a player abandons a game (e.g. starts a new one mid-game).
|
||||
/// </summary>
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||
{
|
||||
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
return Ok();
|
||||
|
||||
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
|
||||
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
|
||||
|
||||
if (!isWhitePlayer && !isBlackPlayer)
|
||||
return StatusCode(403, "You are not a player in this game.");
|
||||
|
||||
gameState.IsForfeited = true;
|
||||
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
|
||||
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,7 +332,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||
@@ -214,7 +346,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
[HttpGet("{gameId}/legalMoves")]
|
||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
if (!gameStore.TryGet(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||
@@ -226,34 +358,4 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
||||
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_gameRemovalTasks.ContainsKey(id))
|
||||
{
|
||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
|
||||
if (_games[id].Computer is not null)
|
||||
await _games[id].Computer!.DisposeAsync();
|
||||
|
||||
_games.Remove(id, out _);
|
||||
_gameRemovalTasks.Remove(id, out _);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
|
||||
if (_games[id].Computer is not null)
|
||||
await _games[id].Computer!.DisposeAsync();
|
||||
|
||||
_games.Remove(id, out _);
|
||||
_gameRemovalTasks.Remove(id, out _);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Hubs;
|
||||
|
||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||
}
|
||||
|
||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
||||
{
|
||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
||||
}
|
||||
|
||||
public async Task LeaveWebsocketGroup(string gameId)
|
||||
{
|
||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Membership and peak reporting for acoustic ranging rooms. Nothing that arrives here is audio:
|
||||
/// a report is a handful of sample indices, and the geometry is solved on the clients.
|
||||
/// </summary>
|
||||
public class EchoHub(IEchoRoomStore rooms) : Hub
|
||||
{
|
||||
/// <summary>Join (or create) a room and receive an id plus the current roster.</summary>
|
||||
public async Task<EchoJoinResult> JoinRoom(string roomCode, string displayName, int sampleRate)
|
||||
{
|
||||
var result = rooms.Join(roomCode, Context.ConnectionId, displayName, sampleRate);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(result.RoomCode));
|
||||
await Clients.Group(GroupFor(result.RoomCode)).SendAsync("RoomChanged", result.Room);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>File this device's arrival indices for the room's open round.</summary>
|
||||
public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
|
||||
|
||||
/// <summary>
|
||||
/// Server clock, for estimating each device's offset from it. Only needs to be good to a few
|
||||
/// tens of milliseconds: it decides which chirp is whose, never how far away anything is.
|
||||
/// </summary>
|
||||
public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
public async Task<bool> LeaveRoom() => await RemoveFromRoom();
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
await RemoveFromRoom();
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
internal static string GroupFor(string roomCode) => $"echo:{roomCode.ToUpperInvariant()}";
|
||||
|
||||
private async Task<bool> RemoveFromRoom()
|
||||
{
|
||||
var (roomCode, room) = rooms.Leave(Context.ConnectionId);
|
||||
if (roomCode is null) return false;
|
||||
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupFor(roomCode));
|
||||
if (room is not null) await Clients.Group(GroupFor(roomCode)).SendAsync("RoomChanged", room);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,4 +29,16 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
|
||||
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
|
||||
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
|
||||
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
|
||||
managed reference to the native DLL. -->
|
||||
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
|
||||
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DateTime PublishedDate { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>A device taking part in a ranging room.</summary>
|
||||
public sealed class EchoDevice
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string ConnectionId { get; init; }
|
||||
public required string DisplayName { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
|
||||
/// </summary>
|
||||
public sealed class EchoRound
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public int SlotMilliseconds { get; init; }
|
||||
public DateTimeOffset Deadline { get; init; }
|
||||
public Dictionary<string, EchoPeakReport> Reports { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one device heard. Peaks are fractional sample indices into that device's own continuous
|
||||
/// recording, one per slot, null where a chirp was not detected. Nothing else is ever uploaded.
|
||||
/// </summary>
|
||||
public sealed class EchoPeakReport
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string RoundId { get; init; }
|
||||
public int Slot { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public double Epsilon { get; init; }
|
||||
public double?[] Peaks { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class EchoJoinResult
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string RoomCode { get; init; }
|
||||
public required EchoRoomSnapshot Room { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EchoRoomSnapshot
|
||||
{
|
||||
public required string RoomCode { get; init; }
|
||||
public required EchoDeviceSnapshot[] Devices { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EchoDeviceSnapshot
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Tells every device when to chirp: its own slot index and how long a slot lasts.</summary>
|
||||
public sealed class EchoRoundSchedule
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public int SlotMilliseconds { get; init; }
|
||||
public int TailMilliseconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When slot zero should sound, in server time, set far enough ahead that every device can
|
||||
/// receive the message and book the playback before it arrives. Devices schedule against this
|
||||
/// rather than against message arrival, so one slow client cannot drag its chirp into another
|
||||
/// device's slot and invalidate the round for everybody.
|
||||
/// </summary>
|
||||
public long StartsAtUnixMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
|
||||
/// so the server never needs the ranging maths.
|
||||
/// </summary>
|
||||
public sealed class EchoRoundResult
|
||||
{
|
||||
public required string RoundId { get; init; }
|
||||
public required string[] SlotOrder { get; init; }
|
||||
public required EchoPeakReport[] Reports { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class ForfeitDto
|
||||
{
|
||||
public Guid GameId { get; set; }
|
||||
public Guid PlayerId { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
@@ -28,10 +29,25 @@ public class GameState
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
public bool IsForfeited { get; set; } = false;
|
||||
|
||||
// The color that won, when the game ended by forfeit (null while the game is live).
|
||||
public PieceColor? Winner { get; set; }
|
||||
|
||||
// Keep a history of moves if desired
|
||||
public List<string> MoveHistory { get; set; }
|
||||
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||
public List<string> SanHistory { get; set; }
|
||||
|
||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||
public int HalfmoveClock { get; set; }
|
||||
|
||||
// A list of all pieces to quickly reference them (optional but convenient).
|
||||
// Alternatively, you can iterate the Board array.
|
||||
public List<ChessPiece> Pieces { get; set; }
|
||||
@@ -42,8 +58,22 @@ public class GameState
|
||||
public Guid BlackPlayerId { get; set; }
|
||||
|
||||
public bool IsVsComputer { get; set; } = false;
|
||||
public bool IsComputerVsComputer { get; set; } = false;
|
||||
|
||||
public Stockfish? Computer { get; set; }
|
||||
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
||||
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
||||
public IChessEngine? WhiteComputer { get; set; }
|
||||
public IChessEngine? BlackComputer { get; set; }
|
||||
|
||||
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
||||
// are the learning engine, and lets spectators see who is playing.
|
||||
public ChessEngineKind WhiteEngineKind { get; set; }
|
||||
public ChessEngineKind BlackEngineKind { get; set; }
|
||||
|
||||
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
||||
// learned engine). The engine records each played position into it and applies the
|
||||
// result on game over.
|
||||
public nint Trainer { get; set; }
|
||||
|
||||
// optional: convenience
|
||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||
@@ -54,5 +84,7 @@ public class GameState
|
||||
Board = new ChessPiece[8, 8];
|
||||
Pieces = [];
|
||||
MoveHistory = [];
|
||||
PositionHistory = [];
|
||||
SanHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single piece as sent to the client. Captured pieces keep their original
|
||||
/// type and color; their position is meaningless and omitted.
|
||||
/// </summary>
|
||||
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||
|
||||
/// <summary>
|
||||
/// The full board state pushed to clients. The same shape is returned by the
|
||||
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||
/// client always renders from one authoritative payload instead of re-fetching.
|
||||
/// </summary>
|
||||
public record GameStateDto(
|
||||
Guid GameId,
|
||||
string CurrentPlayer,
|
||||
bool IsCheck,
|
||||
bool IsCheckmate,
|
||||
bool IsStalemate,
|
||||
bool IsThreefoldRepetition,
|
||||
string? EnPassantTarget,
|
||||
bool WhiteCanCastleKingside,
|
||||
bool WhiteCanCastleQueenside,
|
||||
bool BlackCanCastleKingside,
|
||||
bool BlackCanCastleQueenside,
|
||||
IReadOnlyList<ChessPieceDto> Pieces,
|
||||
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||
IReadOnlyList<string> MoveHistory,
|
||||
// Moves in standard algebraic notation, for the move-list panel.
|
||||
IReadOnlyList<string> SanHistory,
|
||||
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||
int Version);
|
||||
|
||||
public static class GameStateMapper
|
||||
{
|
||||
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||
gameState.GameId,
|
||||
gameState.CurrentPlayer.ToString(),
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
gameState.IsThreefoldRepetition,
|
||||
gameState.EnPassantTarget?.ToString(),
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
gameState.BlackCanCastleKingside,
|
||||
gameState.BlackCanCastleQueenside,
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row >= 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.Pieces
|
||||
.Where(p => p.Position.Row < 0)
|
||||
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||
.ToList(),
|
||||
gameState.MoveHistory,
|
||||
gameState.SanHistory,
|
||||
gameState.MoveHistory.Count);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
|
||||
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
|
||||
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
|
||||
/// king safety).
|
||||
/// </summary>
|
||||
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
|
||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.BlogModel
|
||||
@{
|
||||
ViewData["Title"] = "Blog - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="blog-index">
|
||||
<h1 class="blog-title">Blog</h1>
|
||||
|
||||
@if (Model.ActiveTag is not null)
|
||||
{
|
||||
<div class="blog-filter">
|
||||
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
|
||||
<a href="/blog">Clear filter</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Posts.Count == 0)
|
||||
{
|
||||
<p class="blog-empty">No posts yet. Check back soon!</p>
|
||||
}
|
||||
|
||||
<div class="blog-list">
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<article class="blog-card">
|
||||
<a href="/blog/@post.Slug" class="blog-card-link">
|
||||
<h2 class="blog-card-title">@post.Title</h2>
|
||||
</a>
|
||||
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<p class="blog-card-summary">@post.Summary</p>
|
||||
<div class="blog-card-tags">
|
||||
@foreach (var tag in post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages;
|
||||
|
||||
public class BlogModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public List<BlogPost> Posts { get; set; } = [];
|
||||
public string? ActiveTag { get; set; }
|
||||
|
||||
public BlogModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(string? tag)
|
||||
{
|
||||
ActiveTag = tag;
|
||||
Posts = tag is not null
|
||||
? await _blogService.GetPostsByTagAsync(tag)
|
||||
: await _blogService.GetAllPostsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@page "/blog/{slug}"
|
||||
@model JoshHeaps.Net.Pages.Blog.PostModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<article class="blog-post">
|
||||
<a href="/blog" class="blog-back">← Back to blog</a>
|
||||
|
||||
<header class="blog-post-header">
|
||||
<h1 class="blog-post-title">@Model.Post.Title</h1>
|
||||
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<div class="blog-post-tags">
|
||||
@foreach (var tag in Model.Post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
@Html.Raw(Model.Post.HtmlContent)
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Blog;
|
||||
|
||||
public class PostModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public BlogPost? Post { get; set; }
|
||||
|
||||
public PostModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string slug)
|
||||
{
|
||||
Post = await _blogService.GetPostBySlugAsync(slug);
|
||||
|
||||
if (Post is null)
|
||||
return NotFound();
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
}
|
||||
|
||||
<div id="chessContainer">
|
||||
<div id="boardContainer">
|
||||
<section id="boardArea">
|
||||
<div class="playerBar" id="topPlayerBar">
|
||||
<span class="playerDot black"></span>
|
||||
<span class="playerName">Opponent</span>
|
||||
<div class="capturedTray" id="captured-top"></div>
|
||||
<span class="advantage" id="advantage-top"></span>
|
||||
</div>
|
||||
|
||||
<div id="chessBoard">
|
||||
<!-- Placeholder squares -->
|
||||
@for (int i = 0; i < 64; i++)
|
||||
@@ -14,16 +21,41 @@
|
||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="textContainer" class="sideContent">
|
||||
<h1>Chess</h1>
|
||||
<p>Click a button to start a game :)</p>
|
||||
</div>
|
||||
<div class="playerBar" id="bottomPlayerBar">
|
||||
<span class="playerDot white"></span>
|
||||
<span class="playerName">You</span>
|
||||
<div class="capturedTray" id="captured-bottom"></div>
|
||||
<span class="advantage" id="advantage-bottom"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<aside id="gamePanel">
|
||||
<header class="panelHeader">
|
||||
<span class="panelLogo">♞</span>
|
||||
<h1>Chess</h1>
|
||||
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
||||
</header>
|
||||
|
||||
<div id="moveList">
|
||||
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
||||
</div>
|
||||
|
||||
<div id="statusLine">Start a game to play.</div>
|
||||
|
||||
<div id="panelButtons">
|
||||
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
|
||||
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
|
||||
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="menuBackdrop" onclick="closeMenu()"></div>
|
||||
|
||||
<div id="mobileBar">
|
||||
<span id="mobileStatus">Start a game to play.</span>
|
||||
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,12 +89,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="colorModal" style="display: none;">
|
||||
<p>Play as:</p>
|
||||
<div id="colorButtonContainer">
|
||||
<button onclick="selectColor('white')">White</button>
|
||||
<button onclick="selectColor('black')">Black</button>
|
||||
<button onclick="selectColor('random')">Random</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/chessLogic.js"></script>
|
||||
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.EchoModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Echo";
|
||||
}
|
||||
|
||||
<div id="echoHeader">
|
||||
<h1>Echo</h1>
|
||||
<p class="echo-blurb">
|
||||
Two devices measure the distance between them by chirping at each other and timing the answer.
|
||||
Each one records continuously and only ever compares arrivals inside its own recording, so no
|
||||
clock synchronisation is needed and the unknown audio latency cancels out.
|
||||
</p>
|
||||
<p class="echo-privacy">Only sample numbers leave your device. The recording never does.</p>
|
||||
</div>
|
||||
|
||||
<div id="echoControls">
|
||||
<label>Room <input id="echoRoom" type="text" maxlength="6" autocomplete="off" spellcheck="false" /></label>
|
||||
<label>Name <input id="echoName" type="text" maxlength="16" placeholder="this device" autocomplete="off" /></label>
|
||||
<label>
|
||||
Speaker to mic (m)
|
||||
<input id="echoEpsilon" type="number" min="0" max="0.5" step="0.01" />
|
||||
</label>
|
||||
<button id="echoJoin">Join and listen</button>
|
||||
<button id="echoLeave" hidden>Leave</button>
|
||||
</div>
|
||||
|
||||
<p id="echoStatus" class="echo-status">Pick a room, then open the same room on a second device.</p>
|
||||
<p class="echo-share">Join link: <a id="echoShareLink" href="#"></a></p>
|
||||
<div id="echoWarnings"></div>
|
||||
|
||||
<div id="echoReadout" class="echo-readout"><span class="echo-readout-idle">not measuring</span></div>
|
||||
|
||||
<div id="echoPanels">
|
||||
<section class="echo-panel">
|
||||
<h2>Devices</h2>
|
||||
<ul id="echoRoster"></ul>
|
||||
</section>
|
||||
<section class="echo-panel">
|
||||
<h2>Ranges</h2>
|
||||
<div id="echoPairs"></div>
|
||||
</section>
|
||||
<section class="echo-panel">
|
||||
<h2>Timing</h2>
|
||||
<p class="echo-hint">How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.</p>
|
||||
<div id="echoDiagnostics"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="echo-panel echo-panel-wide">
|
||||
<h2>Matched filter</h2>
|
||||
<p class="echo-hint">Each spike is a chirp arriving. The bright one is this device hearing itself.</p>
|
||||
<canvas id="echoTrace"></canvas>
|
||||
</section>
|
||||
|
||||
<a class="echo-back" href="/">← Back</a>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoDsp.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoAudio.js"></script>
|
||||
<script src="~/js/EchoScripts/EchoSession.js"></script>
|
||||
<script src="~/js/EchoScripts/echoMain.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/variables.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/echo/echo.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class EchoModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,116 @@
|
||||
@page
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<header id="Header">
|
||||
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
|
||||
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
|
||||
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
|
||||
</header>
|
||||
|
||||
<div id="WelcomeMessage"></div>
|
||||
|
||||
<div id="ProjectsBox">
|
||||
<div id="ChessProject" class="projectContainer">
|
||||
<div class="displayBox diagonal-section-left">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
|
||||
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
||||
</a>
|
||||
</div>
|
||||
<div id="ChessText" class="projectTextRight">
|
||||
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
||||
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
||||
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. 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>
|
||||
<nav id="Header">
|
||||
<span class="site-name">josh heaps</span>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
|
||||
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
|
||||
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
|
||||
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="CompilerProject" class="projectContainer">
|
||||
<div id="CompilerText" class="projectTextLeft">
|
||||
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
|
||||
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
|
||||
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
|
||||
</p>
|
||||
</div>
|
||||
<div class="displayBox diagonal-section-right">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
|
||||
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
|
||||
<section id="Hero">
|
||||
<div id="WelcomeMessage"></div>
|
||||
</section>
|
||||
|
||||
<section id="ProjectsBox">
|
||||
<h2 class="section-title">Projects</h2>
|
||||
<div class="projects-grid">
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Chess</h3>
|
||||
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div id="DemosBox" class="displayBox">
|
||||
<h2 id="DemoHeader">Demos</h2>
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
|
||||
</a>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Compiler</h3>
|
||||
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="project-card">
|
||||
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
|
||||
</a>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Image Cloud Storage</h3>
|
||||
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
|
||||
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo →</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="DemosBox">
|
||||
<h2 class="section-title">Demos</h2>
|
||||
<div id="buttonWrapper">
|
||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
||||
Play Chess
|
||||
</button>
|
||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/particles'">
|
||||
Particle Simulator
|
||||
</button>
|
||||
<button id="MoreFiller" class="demoButton">
|
||||
More coming soon...
|
||||
</button>
|
||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="BlogBox">
|
||||
<h2 class="section-title">Latest Posts</h2>
|
||||
<div id="blogPostsList">
|
||||
@foreach (var post in Model.LatestPosts)
|
||||
{
|
||||
<div class="blog-teaser-item">
|
||||
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
|
||||
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
|
||||
<p class="blog-teaser-summary">@post.Summary</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<a class="blog-view-all" href="/blog">View all posts →</a>
|
||||
</section>
|
||||
|
||||
<footer id="Contacts">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="mailto:[email protected]">
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
|
||||
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
|
||||
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
|
||||
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
|
||||
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@@ -97,4 +118,4 @@
|
||||
<script src="/js/site.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/opacityUpdateObserver.js"></script>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel() : PageModel
|
||||
public class IndexModel(IBlogService blogService) : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
public List<BlogPost> LatestPosts { get; set; } = [];
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var allPosts = await blogService.GetAllPostsAsync();
|
||||
LatestPosts = allPosts.Take(3).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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" />
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WatchModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Watch Chess";
|
||||
}
|
||||
|
||||
<div id="watchHeader">
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<fieldset class="enginePicker">
|
||||
<legend>White</legend>
|
||||
<select id="whiteEngine">
|
||||
<option value="custom">Custom (v1)</option>
|
||||
<option value="customLearned" selected>Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="whiteSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Black</legend>
|
||||
<select id="blackEngine">
|
||||
<option value="custom" selected>Custom (v1)</option>
|
||||
<option value="customLearned">Custom (Learned)</option>
|
||||
<option value="stockfish">Stockfish</option>
|
||||
</select>
|
||||
<select id="blackSkill">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
</fieldset>
|
||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||
<fieldset class="enginePicker">
|
||||
<legend>Auto-train games</legend>
|
||||
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||
</div>
|
||||
|
||||
<div id="gamesFeed"></div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WatchModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WeightsModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Learned Weights";
|
||||
}
|
||||
|
||||
<div id="weightsHeader">
|
||||
<h1>Learned Piece-Square Weights</h1>
|
||||
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
|
||||
<div id="weightsControls">
|
||||
<div class="heatLegend">
|
||||
<span>low</span>
|
||||
<span class="legendBar"></span>
|
||||
<span>high</span>
|
||||
</div>
|
||||
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
|
||||
</div>
|
||||
<a id="backToWatch" href="/watch">← Watch / train</a>
|
||||
</div>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Feature weights</h2>
|
||||
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
|
||||
<div id="featureWeights" class="featurePanel"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Midgame tables</h2>
|
||||
<div id="weightsGridMg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="weightsSection">
|
||||
<h2>Endgame tables</h2>
|
||||
<div id="weightsGridEg" class="weightsGrid"></div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/ChessScripts/Weights.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WeightsModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -12,12 +13,40 @@ builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddHttpClient("BlogApi", client =>
|
||||
{
|
||||
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddSingleton<IBlogService, BlogService>();
|
||||
builder.Services.AddSingleton<IChessService, ChessService>();
|
||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||
|
||||
builder.Services.Configure<EchoRoundSettings>(configuration.GetSection(EchoRoundSettings.SectionName));
|
||||
builder.Services.AddSingleton(provider =>
|
||||
provider.GetRequiredService<IOptions<EchoRoundSettings>>().Value);
|
||||
builder.Services.AddSingleton<IEchoRoomStore, EchoRoomStore>();
|
||||
builder.Services.AddHostedService<EchoRoundService>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
|
||||
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||
builder.Services.AddHostedService<AutoTrainingService>();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
@@ -50,4 +79,6 @@ app.MapControllers();
|
||||
|
||||
app.MapHub<ChessHub>("/chessHub");
|
||||
|
||||
app.MapHub<EchoHub>("/echoHub");
|
||||
|
||||
app.Run();
|
||||
@@ -11,6 +11,7 @@
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
@@ -20,6 +21,7 @@
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": true,
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
||||
|
||||
Binary file not shown.
@@ -8,31 +8,23 @@ public class AutoIpUpdateService(
|
||||
ILogger<AutoIpUpdateService> log)
|
||||
: BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
|
||||
private static readonly HttpClient httpClient = new();
|
||||
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)
|
||||
{
|
||||
IsEnabled = true;
|
||||
var timer = new PeriodicTimer(CheckInterval);
|
||||
AAAARecord dnsRecord = await GetDnsRecordAsync();
|
||||
string lastKnownIp = dnsRecord.Content;
|
||||
var timer = new PeriodicTimer(_checkInterval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stop))
|
||||
{
|
||||
try
|
||||
{
|
||||
string currentIp = await GetPublicIpAsync() ?? "";
|
||||
|
||||
if (lastKnownIp != currentIp)
|
||||
{
|
||||
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
|
||||
|
||||
lastKnownIp = currentIp;
|
||||
}
|
||||
await UpdateIpAddressIfChanged();
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutting down */ }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
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()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
|
||||
return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -55,7 +69,7 @@ public class AutoIpUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AAAARecord> GetDnsRecordAsync()
|
||||
private async Task<RecordList> GetDnsRecordAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -64,8 +78,8 @@ public class AutoIpUpdateService(
|
||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
||||
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
|
||||
Console.WriteLine(await result.Content.ReadAsStringAsync());
|
||||
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
||||
return records!.Result[0];
|
||||
var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
||||
return records!;
|
||||
}
|
||||
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();
|
||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
|
||||
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,
|
||||
name = "@",
|
||||
proxied = true,
|
||||
ttl = 3600,
|
||||
type = "AAAA"
|
||||
proxied = record.Proxied,
|
||||
};
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -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("content")] string Content,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("proxied")] bool Proxied,
|
||||
[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,73 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||
/// ChessEngine:AutoTrain config flag.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingService(
|
||||
ISelfPlayCoordinator coordinator,
|
||||
AutoTrainingSettings settings,
|
||||
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||
{
|
||||
private const int LearnedSkill = 6;
|
||||
private const int StockfishSkill = 20;
|
||||
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var running = new List<Task>();
|
||||
int started = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
running.RemoveAll(t => t.IsCompleted);
|
||||
|
||||
int desired = settings.GameCount;
|
||||
bool startFailed = false;
|
||||
|
||||
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||
var config = started++ % 2 == 0
|
||||
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||
|
||||
try
|
||||
{
|
||||
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||
running.Add(completion);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||
// persistent failure doesn't spin a tight loop, then try again.
|
||||
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||
startFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (startFailed)
|
||||
await Task.Delay(_restartBackoff, stoppingToken);
|
||||
else if (running.Count > 0)
|
||||
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||
// count increase promptly).
|
||||
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||
else
|
||||
// Pool is empty (count is 0) — just poll for the count to change.
|
||||
await Task.Delay(_pollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||
/// </summary>
|
||||
public sealed class AutoTrainingSettings
|
||||
{
|
||||
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||
public const int MaxGames = 16;
|
||||
|
||||
private int _gameCount;
|
||||
|
||||
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||
|
||||
/// <summary>
|
||||
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||
/// </summary>
|
||||
public int GameCount
|
||||
{
|
||||
get => Volatile.Read(ref _gameCount);
|
||||
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||
}
|
||||
|
||||
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public class BlogService : IBlogService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<BlogService> _logger;
|
||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("BlogApi");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetAllPostsAsync()
|
||||
{
|
||||
return await GetCachedAsync("all_posts",
|
||||
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
|
||||
{
|
||||
return await GetCachedAsync($"post_{slug}",
|
||||
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
|
||||
{
|
||||
return await GetCachedAsync($"tag_{tag}",
|
||||
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
|
||||
}
|
||||
|
||||
private async Task<T?> FetchAsync<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(path);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
|
||||
return (T?)entry.Value;
|
||||
|
||||
var result = await factory();
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||
}
|
||||
else if (entry is not null)
|
||||
{
|
||||
// API unreachable — serve stale cache
|
||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
||||
return (T?)entry.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Blog cache cleared");
|
||||
}
|
||||
|
||||
private record CacheEntry(object Value, DateTime ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>The available chess engine implementations.</summary>
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom,
|
||||
|
||||
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||
CustomLearned
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
public sealed class ChessEngineOptions
|
||||
{
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the learned-weights file. Leave null to default to
|
||||
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
|
||||
/// production set this to a stable, service-writable location OUTSIDE the deploy
|
||||
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
|
||||
/// trained weights survive deploys and avoid deploy-user vs service-user permission
|
||||
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||
/// </summary>
|
||||
public string? WeightsPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true (and outside Development), a background service continuously plays the learned
|
||||
/// engine against Stockfish to train it. Set to false to stop auto-training without a
|
||||
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||
/// </summary>
|
||||
public bool AutoTrain { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||
/// </summary>
|
||||
public int AutoTrainGameCount { get; set; } = 2;
|
||||
}
|
||||
|
||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||
public sealed class ChessEngineFactory(
|
||||
IOptions<ChessEngineOptions> options,
|
||||
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
|
||||
public IChessEngine Create(int skill) => Create(skill, _kind);
|
||||
|
||||
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
|
||||
{
|
||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class ChessEngineHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all the engine needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The prior positions since the last irreversible move (capture/pawn move), as
|
||||
/// completed FEN strings, oldest first and excluding the current position. This is
|
||||
/// the repetition window the engine needs to detect threefold/50-move draws that a
|
||||
/// single FEN can't express.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
|
||||
{
|
||||
int clock = gs.HalfmoveClock;
|
||||
int count = gs.PositionHistory.Count;
|
||||
int start = count - 1 - clock; // PositionHistory ends with the current position
|
||||
|
||||
if (clock <= 0 || start < 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var fens = new List<string>(clock);
|
||||
|
||||
for (int i = start; i < count - 1; i++)
|
||||
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
|
||||
|
||||
return fens;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -22,12 +22,14 @@ public class ChessService : IChessService
|
||||
|
||||
gameState.Pieces.Clear();
|
||||
gameState.MoveHistory.Clear();
|
||||
gameState.SanHistory.Clear();
|
||||
|
||||
gameState.WhiteCanCastleKingside = true;
|
||||
gameState.WhiteCanCastleQueenside = true;
|
||||
gameState.BlackCanCastleKingside = true;
|
||||
gameState.BlackCanCastleQueenside = true;
|
||||
gameState.EnPassantTarget = null;
|
||||
gameState.HalfmoveClock = 0;
|
||||
|
||||
SetupBlackPieces(gameState);
|
||||
SetupWhitePieces(gameState);
|
||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
||||
gameState.CurrentPlayer = PieceColor.White;
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
gameState.PositionHistory.Clear();
|
||||
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||
}
|
||||
|
||||
private static void SetupBlackPieces(GameState gs)
|
||||
@@ -110,7 +115,6 @@ public class ChessService : IChessService
|
||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
||||
|
||||
if (piece == null) return [];
|
||||
if (piece.Color != gameState.CurrentPlayer) return [];
|
||||
|
||||
var candidateMoves = GenerateCandidateMoves(gameState, piece);
|
||||
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))
|
||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||
|
||||
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
||||
// the check/mate suffix is appended after UpdateCheckStatus.
|
||||
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
||||
|
||||
PerformMove(gameState, piece, targetPos, moveDto);
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||
gameState.MoveHistory.Add(notation);
|
||||
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||
|
||||
var positionKey = PositionKey(gameState);
|
||||
gameState.PositionHistory.Add(positionKey);
|
||||
|
||||
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
||||
gameState.IsThreefoldRepetition = true;
|
||||
|
||||
return new MoveResultDto
|
||||
{
|
||||
@@ -151,14 +166,95 @@ public class ChessService : IChessService
|
||||
Message = "Move successful.",
|
||||
IsCheck = gameState.IsCheck,
|
||||
IsCheckmate = gameState.IsCheckmate,
|
||||
IsStalemate = gameState.IsStalemate
|
||||
IsStalemate = gameState.IsStalemate,
|
||||
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||
/// applied (the check/mate suffix is added by the caller afterward).
|
||||
/// </summary>
|
||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||
{
|
||||
var from = piece.Position;
|
||||
|
||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||
|
||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||
bool isCapture = targetOccupied || isEnPassant;
|
||||
string dest = SquareName(target);
|
||||
|
||||
if (piece.Type == PieceType.Pawn)
|
||||
{
|
||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||
|
||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||
|
||||
if (promotes)
|
||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||
|
||||
return san;
|
||||
}
|
||||
|
||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||
/// target, qualify the origin by file, else rank, else both.
|
||||
/// </summary>
|
||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||
{
|
||||
var rivals = gs.Pieces
|
||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||
.ToList();
|
||||
|
||||
if (rivals.Count == 0)
|
||||
return "";
|
||||
|
||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||
return FileChar(piece.Position.Col).ToString();
|
||||
|
||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||
return (8 - piece.Position.Row).ToString();
|
||||
|
||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||
}
|
||||
|
||||
private static char FileChar(int col) => (char)('a' + col);
|
||||
|
||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||
|
||||
private static string PieceLetter(PieceType type) => type switch
|
||||
{
|
||||
PieceType.Knight => "N",
|
||||
PieceType.Bishop => "B",
|
||||
PieceType.Rook => "R",
|
||||
PieceType.Queen => "Q",
|
||||
PieceType.King => "K",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||
/// </summary>
|
||||
private static string PositionKey(GameState gs)
|
||||
{
|
||||
var fields = gs.ToFen().Split(' ');
|
||||
return string.Join(' ', fields.Take(4));
|
||||
}
|
||||
|
||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||
{
|
||||
var oldPos = piece.Position;
|
||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
||||
|
||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||
|
||||
@@ -180,6 +276,9 @@ public class ChessService : IChessService
|
||||
|
||||
UpdateCastlingRights(gs, piece, oldPos);
|
||||
|
||||
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
||||
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
||||
|
||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||
? PieceColor.Black
|
||||
: PieceColor.White;
|
||||
@@ -529,7 +628,14 @@ public class ChessService : IChessService
|
||||
var ep = gs.EnPassantTarget.Value;
|
||||
|
||||
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
|
||||
moves.Add(ep);
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
@@ -542,11 +648,7 @@ public class ChessService : IChessService
|
||||
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
|
||||
|
||||
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
|
||||
=> GenerateSlidingMoves(gs, piece,
|
||||
[
|
||||
(1, 0), (-1, 0), (0, 1), (0, -1),
|
||||
(1, 1), (1, -1), (-1, 1), (-1, -1)
|
||||
]);
|
||||
=> [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
|
||||
|
||||
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
|
||||
/// through the rules service, and broadcast it to the game's clients.
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IChessService chessService,
|
||||
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||
{
|
||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
|
||||
{
|
||||
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
|
||||
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
|
||||
|
||||
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||
|
||||
return await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
public async Task PlayRandomMoveAsync(GameState state)
|
||||
{
|
||||
var options = chessService.GetAllLegalMoves(state);
|
||||
|
||||
// No legal moves means the game is already over; let the caller's loop detect it.
|
||||
if (options.Count == 0)
|
||||
return;
|
||||
|
||||
var (piece, moves) = options[Random.Shared.Next(options.Count)];
|
||||
var target = moves[Random.Shared.Next(moves.Count)];
|
||||
|
||||
var move = new MoveDto
|
||||
{
|
||||
GameId = state.GameId,
|
||||
PlayerId = CurrentPlayerId(state),
|
||||
PieceId = piece.Id,
|
||||
SourceRow = piece.Position.Row,
|
||||
SourceCol = piece.Position.Col,
|
||||
TargetRow = target.Row,
|
||||
TargetCol = target.Col,
|
||||
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
|
||||
};
|
||||
|
||||
await ApplyAndBroadcastAsync(state, move);
|
||||
}
|
||||
|
||||
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
|
||||
{
|
||||
var result = chessService.MakeMove(state, move);
|
||||
|
||||
// Record the played position for training (no-op for non-training games).
|
||||
if (state.Trainer != nint.Zero)
|
||||
weightsStore.Record(state.Trainer, state.ToFen());
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||
|
||||
return (move, result);
|
||||
}
|
||||
|
||||
private static Guid CurrentPlayerId(GameState state) =>
|
||||
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>Which evaluation the native engine uses.</summary>
|
||||
public enum EngineVariant
|
||||
{
|
||||
/// <summary>The hand-crafted evaluation.</summary>
|
||||
Classic,
|
||||
|
||||
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
|
||||
Learned
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
|
||||
/// </summary>
|
||||
public sealed partial class CustomChessEngine : IChessEngine
|
||||
{
|
||||
private readonly EngineSafeHandle _handle;
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||
{
|
||||
Skill = skill;
|
||||
|
||||
// weights= must come last: the native side reads the path as the rest of the
|
||||
// string, which lets it contain ';' and spaces.
|
||||
var options = variant == EngineVariant.Learned
|
||||
? $"skill={skill};variant=learned;weights={weightsPath}"
|
||||
: $"skill={skill}";
|
||||
|
||||
var handle = NativeMethods.engine_create(options);
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||
|
||||
_handle = new EngineSafeHandle(handle);
|
||||
}
|
||||
|
||||
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
|
||||
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
|
||||
|
||||
private unsafe string GetBestMove(string fen, string history)
|
||||
{
|
||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||
byte* buffer = stackalloc byte[bufferLength];
|
||||
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
|
||||
|
||||
if (code != 0)
|
||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||
|
||||
return Marshal.PtrToStringUTF8((IntPtr)buffer)
|
||||
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (added)
|
||||
_handle.DangerousRelease();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handle.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
|
||||
private sealed class EngineSafeHandle : SafeHandle
|
||||
{
|
||||
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
NativeMethods.engine_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
||||
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
||||
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||
/// </summary>
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
static NativeMethods() =>
|
||||
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
|
||||
|
||||
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibName)
|
||||
return IntPtr.Zero;
|
||||
|
||||
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "chess_engine.dll"
|
||||
: "libchess_engine.so";
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
|
||||
|
||||
if (NativeLibrary.TryLoad(path, out var handle))
|
||||
return handle;
|
||||
|
||||
return NativeLibrary.Load(libraryName, assembly, searchPath);
|
||||
}
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr engine_create(string? options);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
|
||||
// ---- Learned-weights / training ABI (see chess_engine.h) ----
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void learned_load(string path);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr trainer_create();
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_record(IntPtr trainer, string fen);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void trainer_destroy(IntPtr trainer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Collections.Concurrent;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ranging rooms. Singleton: rooms are process-wide and short-lived, and the site runs
|
||||
/// as a single instance, so there is nothing to persist and no backplane to coordinate.
|
||||
/// </summary>
|
||||
public sealed class EchoRoomStore : IEchoRoomStore
|
||||
{
|
||||
private sealed class Room
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public List<EchoDevice> Devices { get; } = [];
|
||||
public EchoRound? Round { get; set; }
|
||||
public int RoundCounter { get; set; }
|
||||
public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _roomsByConnection = [];
|
||||
|
||||
public EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate)
|
||||
{
|
||||
var room = _rooms.GetOrAdd(roomCode, code => new Room { Code = code });
|
||||
var device = new EchoDevice
|
||||
{
|
||||
DeviceId = Guid.NewGuid().ToString("N")[..8],
|
||||
ConnectionId = connectionId,
|
||||
DisplayName = displayName,
|
||||
SampleRate = sampleRate
|
||||
};
|
||||
|
||||
lock (room)
|
||||
{
|
||||
room.Devices.RemoveAll(existing => existing.ConnectionId == connectionId);
|
||||
room.Devices.Add(device);
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
_roomsByConnection[connectionId] = room.Code;
|
||||
|
||||
return new EchoJoinResult { DeviceId = device.DeviceId, RoomCode = room.Code, Room = SnapshotLocked(room) };
|
||||
}
|
||||
}
|
||||
|
||||
public (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId)
|
||||
{
|
||||
if (!_roomsByConnection.TryRemove(connectionId, out var roomCode)) return (null, null);
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return (roomCode, null);
|
||||
|
||||
lock (room)
|
||||
{
|
||||
room.Devices.RemoveAll(device => device.ConnectionId == connectionId);
|
||||
room.Round = null;
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
|
||||
if (room.Devices.Count == 0) _rooms.TryRemove(roomCode, out _);
|
||||
|
||||
return (roomCode, SnapshotLocked(room));
|
||||
}
|
||||
}
|
||||
|
||||
public EchoRoomSnapshot? Snapshot(string roomCode)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room) return SnapshotLocked(room);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> MeasurableRooms =>
|
||||
[.. _rooms.Values.Where(room => room.Devices.Count >= 2).Select(room => room.Code)];
|
||||
|
||||
public EchoRoundSchedule? StartRound(
|
||||
string roomCode,
|
||||
int slotMilliseconds,
|
||||
int tailMilliseconds,
|
||||
TimeSpan lead,
|
||||
TimeSpan grace)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
if (room.Round is not null || room.Devices.Count < 2) return null;
|
||||
|
||||
var slotOrder = RotatedSlotOrder(room);
|
||||
var startsAt = DateTimeOffset.UtcNow + lead;
|
||||
var duration = TimeSpan.FromMilliseconds(slotOrder.Length * slotMilliseconds + tailMilliseconds);
|
||||
|
||||
room.Round = new EchoRound
|
||||
{
|
||||
RoundId = Guid.NewGuid().ToString("N")[..12],
|
||||
SlotOrder = slotOrder,
|
||||
SlotMilliseconds = slotMilliseconds,
|
||||
Deadline = startsAt + duration + grace
|
||||
};
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
|
||||
return new EchoRoundSchedule
|
||||
{
|
||||
RoundId = room.Round.RoundId,
|
||||
SlotOrder = slotOrder,
|
||||
SlotMilliseconds = slotMilliseconds,
|
||||
TailMilliseconds = tailMilliseconds,
|
||||
StartsAtUnixMs = startsAt.ToUnixTimeMilliseconds()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool Report(string connectionId, EchoPeakReport report)
|
||||
{
|
||||
var device = FindDevice(connectionId, out var room);
|
||||
if (device is null || room is null) return false;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
if (room.Round?.RoundId != report.RoundId) return false;
|
||||
if (!room.Round.SlotOrder.Contains(device.DeviceId)) return false;
|
||||
|
||||
room.Round.Reports[device.DeviceId] = report;
|
||||
room.LastActivity = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public EchoRoundResult? TryCloseRound(string roomCode)
|
||||
{
|
||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
||||
|
||||
lock (room)
|
||||
{
|
||||
var round = room.Round;
|
||||
if (round is null) return null;
|
||||
|
||||
var everyoneReported = round.SlotOrder.All(round.Reports.ContainsKey);
|
||||
if (!everyoneReported && DateTimeOffset.UtcNow < round.Deadline) return null;
|
||||
|
||||
room.Round = null;
|
||||
|
||||
return new EchoRoundResult
|
||||
{
|
||||
RoundId = round.RoundId,
|
||||
SlotOrder = round.SlotOrder,
|
||||
Reports = [.. round.SlotOrder.Where(round.Reports.ContainsKey).Select(id => round.Reports[id])]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public int PruneIdle(TimeSpan idleFor)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - idleFor;
|
||||
var stale = _rooms.Values.Where(room => room.LastActivity < cutoff).Select(room => room.Code).ToList();
|
||||
|
||||
foreach (var code in stale) _rooms.TryRemove(code, out _);
|
||||
|
||||
return stale.Count;
|
||||
}
|
||||
|
||||
private static EchoRoomSnapshot SnapshotLocked(Room room) =>
|
||||
new()
|
||||
{
|
||||
RoomCode = room.Code,
|
||||
Devices =
|
||||
[
|
||||
.. room.Devices.Select(device => new EchoDeviceSnapshot
|
||||
{
|
||||
DeviceId = device.DeviceId,
|
||||
DisplayName = device.DisplayName,
|
||||
SampleRate = device.SampleRate
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
private static string[] RotatedSlotOrder(Room room)
|
||||
{
|
||||
var offset = room.RoundCounter++ % room.Devices.Count;
|
||||
return [.. room.Devices.Skip(offset).Concat(room.Devices.Take(offset)).Select(device => device.DeviceId)];
|
||||
}
|
||||
|
||||
private EchoDevice? FindDevice(string connectionId, out Room? room)
|
||||
{
|
||||
room = null;
|
||||
if (!_roomsByConnection.TryGetValue(connectionId, out var roomCode)) return null;
|
||||
if (!_rooms.TryGetValue(roomCode, out room)) return null;
|
||||
|
||||
lock (room) return room.Devices.FirstOrDefault(device => device.ConnectionId == connectionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class EchoRoundSettings
|
||||
{
|
||||
public const string SectionName = "Echo";
|
||||
|
||||
/// <summary>
|
||||
/// Slot length. Wide enough that no device's chirp can land in another's search window once
|
||||
/// unknown output latency (tens of milliseconds, different per device) and message jitter are
|
||||
/// accounted for.
|
||||
/// </summary>
|
||||
public int SlotMilliseconds { get; set; } = 500;
|
||||
|
||||
/// <summary>Time after the last chirp for propagation, detection and reporting.</summary>
|
||||
public int TailMilliseconds { get; set; } = 700;
|
||||
|
||||
public int GraceMilliseconds { get; set; } = 1500;
|
||||
public int GapMilliseconds { get; set; } = 250;
|
||||
public int IdleRoomMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// How far ahead a round is announced. Must exceed the worst message delivery plus client
|
||||
/// stall, or a device will find its slot already gone and sit the round out.
|
||||
/// </summary>
|
||||
public int LeadMilliseconds { get; set; } = 600;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives continuous ranging: opens a round for every room that has two or more devices, closes it
|
||||
/// once everyone has reported or the deadline passes, and broadcasts the raw peak table.
|
||||
/// </summary>
|
||||
public sealed class EchoRoundService(
|
||||
IEchoRoomStore rooms,
|
||||
IHubContext<EchoHub> hub,
|
||||
EchoRoundSettings settings,
|
||||
ILogger<EchoRoundService> logger) : BackgroundService
|
||||
{
|
||||
private readonly Dictionary<string, DateTimeOffset> _nextRoundAllowedAt = [];
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var lastPrune = DateTimeOffset.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync();
|
||||
lastPrune = PruneIfDue(lastPrune);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Echo round tick failed");
|
||||
}
|
||||
|
||||
await Task.Delay(50, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickAsync()
|
||||
{
|
||||
foreach (var roomCode in rooms.MeasurableRooms)
|
||||
{
|
||||
await CloseFinishedRoundAsync(roomCode);
|
||||
await OpenRoundIfDueAsync(roomCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseFinishedRoundAsync(string roomCode)
|
||||
{
|
||||
var result = rooms.TryCloseRound(roomCode);
|
||||
if (result is null) return;
|
||||
|
||||
_nextRoundAllowedAt[roomCode] = DateTimeOffset.UtcNow.AddMilliseconds(settings.GapMilliseconds);
|
||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundComplete", result);
|
||||
}
|
||||
|
||||
private async Task OpenRoundIfDueAsync(string roomCode)
|
||||
{
|
||||
if (_nextRoundAllowedAt.TryGetValue(roomCode, out var earliest) && DateTimeOffset.UtcNow < earliest) return;
|
||||
|
||||
var schedule = rooms.StartRound(
|
||||
roomCode,
|
||||
settings.SlotMilliseconds,
|
||||
settings.TailMilliseconds,
|
||||
TimeSpan.FromMilliseconds(settings.LeadMilliseconds),
|
||||
TimeSpan.FromMilliseconds(settings.GraceMilliseconds));
|
||||
|
||||
if (schedule is null) return;
|
||||
|
||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundStarting", schedule);
|
||||
}
|
||||
|
||||
private DateTimeOffset PruneIfDue(DateTimeOffset lastPrune)
|
||||
{
|
||||
if (DateTimeOffset.UtcNow - lastPrune < TimeSpan.FromMinutes(1)) return lastPrune;
|
||||
|
||||
var pruned = rooms.PruneIdle(TimeSpan.FromMinutes(settings.IdleRoomMinutes));
|
||||
if (pruned > 0) logger.LogInformation("Pruned {Count} idle echo room(s)", pruned);
|
||||
|
||||
return DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Concurrent;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
|
||||
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
|
||||
/// </summary>
|
||||
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
|
||||
|
||||
public void Add(GameState game) => _games[game.GameId] = game;
|
||||
|
||||
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
|
||||
|
||||
public bool Contains(Guid id) => _games.ContainsKey(id);
|
||||
|
||||
public IReadOnlyCollection<GameState> All => [.. _games.Values];
|
||||
|
||||
public void ScheduleRemove(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_removalCts.TryRemove(id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_removalCts[id] = cts;
|
||||
|
||||
_removalTasks[id] = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cts.Token);
|
||||
|
||||
if (_games.TryGetValue(id, out var game))
|
||||
{
|
||||
if (game.WhiteComputer is not null)
|
||||
await game.WhiteComputer.DisposeAsync();
|
||||
if (game.BlackComputer is not null)
|
||||
await game.BlackComputer.DisposeAsync();
|
||||
|
||||
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
|
||||
if (game.Trainer != nint.Zero)
|
||||
{
|
||||
weightsStore.DestroyTrainer(game.Trainer);
|
||||
game.Trainer = nint.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
_games.Remove(id, out _);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||
_removalCts.TryRemove(id, out _);
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
|
||||
/// On construction it points the native engine at the weights file; everything else delegates
|
||||
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
|
||||
/// </summary>
|
||||
public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
||||
{
|
||||
private const int Pieces = 6; // Pawn..King
|
||||
private const int Squares = 64;
|
||||
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
||||
|
||||
public string WeightsFilePath { get; }
|
||||
|
||||
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
|
||||
{
|
||||
// Prefer the configured path (production points this outside the deploy dir); fall
|
||||
// back to the content root for local dev.
|
||||
var configured = options.Value.WeightsPath;
|
||||
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
|
||||
: configured;
|
||||
|
||||
// A missing/unwritable/misconfigured path must not take down the whole app — the
|
||||
// learned engine just plays from a neutral table and can't persist training.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
|
||||
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
|
||||
"play from a neutral table and training will not persist. In production set " +
|
||||
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
|
||||
WeightsFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
|
||||
|
||||
public void Record(nint trainer, string fen) =>
|
||||
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
|
||||
|
||||
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
|
||||
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
|
||||
|
||||
public void DestroyTrainer(nint trainer) =>
|
||||
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
|
||||
|
||||
public LearnedWeightsSnapshot Snapshot()
|
||||
{
|
||||
const int total = Pieces * Squares * 2 + Features;
|
||||
var buffer = new int[total];
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (int* p = buffer)
|
||||
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
|
||||
}
|
||||
|
||||
var mg = new int[Pieces][];
|
||||
var eg = new int[Pieces][];
|
||||
|
||||
for (int piece = 0; piece < Pieces; piece++)
|
||||
{
|
||||
mg[piece] = new int[Squares];
|
||||
eg[piece] = new int[Squares];
|
||||
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
|
||||
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
|
||||
}
|
||||
|
||||
var features = new int[Features];
|
||||
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
|
||||
|
||||
return new LearnedWeightsSnapshot(mg, eg, features);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class PgnExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||
/// </summary>
|
||||
public static string ToPgn(this GameState gs)
|
||||
{
|
||||
var result = ResultTag(gs);
|
||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||
sb.AppendLine($"[Result \"{result}\"]");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
sb.Append(i / 2 + 1).Append(". ");
|
||||
|
||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||
}
|
||||
|
||||
sb.Append(result);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResultTag(GameState gs)
|
||||
{
|
||||
if (gs.IsCheckmate)
|
||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||
|
||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||
return "1/2-1/2";
|
||||
|
||||
if (gs.IsForfeited)
|
||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||
/// </summary>
|
||||
public sealed class SelfPlayCoordinator(
|
||||
IChessService chessService,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
ILearnedWeightsStore weightsStore,
|
||||
IGameStore gameStore) : ISelfPlayCoordinator
|
||||
{
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Plies of random legal moves at the start of a training game, so self-play and
|
||||
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||
private const int _openingRandomPlies = 4;
|
||||
|
||||
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||
IChessEngine blackComputer;
|
||||
try
|
||||
{
|
||||
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
throw;
|
||||
}
|
||||
|
||||
var gameState = chessService.CreateNewGame();
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.WhiteEngineKind = config.WhiteKind;
|
||||
gameState.BlackEngineKind = config.BlackKind;
|
||||
gameState.WhiteComputer = whiteComputer;
|
||||
gameState.BlackComputer = blackComputer;
|
||||
|
||||
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||
gameState.Trainer = weightsStore.CreateTrainer();
|
||||
|
||||
gameStore.Add(gameState);
|
||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||
|
||||
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||
return (gameState.GameId, completion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||
/// </summary>
|
||||
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
bool aborted = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Give spectators a moment to join the SignalR group before the first move.
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
|
||||
// Training games open with random moves so they don't replay the same line.
|
||||
if (gameState.Trainer != nint.Zero)
|
||||
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||
{
|
||||
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
|
||||
var lastMoveCount = gameState.MoveHistory.Count;
|
||||
var lastProgress = DateTime.UtcNow;
|
||||
|
||||
while (IsLive(gameState, cancellationToken))
|
||||
{
|
||||
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||
|
||||
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||
{
|
||||
lastMoveCount = gameState.MoveHistory.Count;
|
||||
lastProgress = DateTime.UtcNow;
|
||||
}
|
||||
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||
throw new TimeoutException("no move played within the idle timeout");
|
||||
|
||||
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||
catch (TimeoutException)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
aborted = true;
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||
}
|
||||
|
||||
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||
// idle past the timeout) records nothing and just frees its trainer.
|
||||
if (aborted)
|
||||
DiscardTraining(gameState);
|
||||
else
|
||||
ApplyLearning(gameState);
|
||||
|
||||
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||
if (gameStore.Contains(gameState.GameId))
|
||||
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||
/// observed so it isn't an unobserved task exception.
|
||||
/// </summary>
|
||||
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||
{
|
||||
var play = orchestrator.PlayAsync(gameState);
|
||||
try
|
||||
{
|
||||
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||
!cancellationToken.IsCancellationRequested
|
||||
&& gameStore.Contains(gameState.GameId)
|
||||
&& !IsGameOver(gameState);
|
||||
|
||||
private static bool IsGameOver(GameState gameState) =>
|
||||
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||
/// </summary>
|
||||
private void ApplyLearning(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||
/// idle-timed-out game teaches the model nothing.
|
||||
/// </summary>
|
||||
private void DiscardTraining(GameState gameState)
|
||||
{
|
||||
if (gameState.Trainer == nint.Zero)
|
||||
return;
|
||||
|
||||
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||
gameState.Trainer = nint.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||
/// </summary>
|
||||
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||
{
|
||||
winner = PieceColor.White;
|
||||
weight = 1.0;
|
||||
|
||||
if (gameState.IsCheckmate)
|
||||
{
|
||||
// The side to move is the mated one, so the winner is the other color.
|
||||
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||
{
|
||||
var (white, black) = MaterialCounts(gameState);
|
||||
|
||||
if (white == black)
|
||||
return false; // a balanced draw carries no signal
|
||||
|
||||
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||
weight = 0.5;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // forfeit / unfinished
|
||||
}
|
||||
|
||||
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||
{
|
||||
int white = 0, black = 0;
|
||||
|
||||
for (int row = 0; row < 8; row++)
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var piece = gameState.Board[row, col];
|
||||
|
||||
if (piece is null)
|
||||
continue;
|
||||
|
||||
int value = piece.Type switch
|
||||
{
|
||||
PieceType.Pawn => 1,
|
||||
PieceType.Knight => 3,
|
||||
PieceType.Bishop => 3,
|
||||
PieceType.Rook => 5,
|
||||
PieceType.Queen => 9,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
if (piece.Color == PieceColor.White)
|
||||
white += value;
|
||||
else
|
||||
black += value;
|
||||
}
|
||||
|
||||
return (white, black);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class Stockfish : IAsyncDisposable
|
||||
public sealed class Stockfish : IChessEngine
|
||||
{
|
||||
private readonly Process _p;
|
||||
private readonly StreamWriter _stdin;
|
||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||
private readonly int _skill;
|
||||
|
||||
public int Skill => _skill;
|
||||
|
||||
public Stockfish(int skill = 20, int hash = 256)
|
||||
{
|
||||
_skill = skill;
|
||||
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
|
||||
|
||||
string exePath = Path.Combine(baseDir, "Resources", fileName);
|
||||
|
||||
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
|
||||
"Ensure the file is present in the Resources folder of your project.");
|
||||
}
|
||||
|
||||
|
||||
Console.Write(exePath);
|
||||
|
||||
_p = new Process
|
||||
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
WaitFor("readyok").GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<string> GetBestMoveAsync(string fen)
|
||||
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
||||
{
|
||||
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
||||
Send($"position fen {fen}");
|
||||
Send($"go depth {_skill}");
|
||||
string? best = null;
|
||||
@@ -109,145 +107,4 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
await _p.WaitForExitAsync();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
|
||||
{
|
||||
var move = await GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var moveDto = move.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, moveDto);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockfishHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(" 0 ").Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
public interface IBlogService
|
||||
{
|
||||
Task<List<BlogPost>> GetAllPostsAsync();
|
||||
Task<BlogPost?> GetPostBySlugAsync(string slug);
|
||||
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
|
||||
void ClearCache();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
|
||||
/// Selects a move for a position; it does not enforce rules or broadcast updates.
|
||||
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
|
||||
/// </summary>
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The engine's playing strength / search depth.</summary>
|
||||
int Skill { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||
/// </summary>
|
||||
/// <param name="fen">The current position as a FEN string.</param>
|
||||
/// <param name="historyFens">
|
||||
/// Prior positions since the last irreversible move, oldest first, excluding the
|
||||
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
|
||||
/// May be empty.
|
||||
/// </param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
|
||||
/// </summary>
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game using the configured default engine.
|
||||
/// The caller owns and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game using an explicitly chosen engine
|
||||
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
|
||||
/// and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <param name="kind">The engine implementation to create.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill, ChessEngineKind kind);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
|
||||
/// the rules service, and broadcasts the result to the game's clients.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the side-to-move's engine pick a move for the current position, applies it, and
|
||||
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a move in.</param>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
|
||||
|
||||
/// <summary>
|
||||
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
|
||||
/// broadcasting it. Used to randomize the opening of training games so self-play and
|
||||
/// engine-vs-engine games don't replay the same line every time.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a random move in.</param>
|
||||
Task PlayRandomMoveAsync(GameState state);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of ranging rooms. Holds only device identity and the sample indices each
|
||||
/// device reported, so the server is a scheduler and a relay — it never sees audio.
|
||||
/// </summary>
|
||||
public interface IEchoRoomStore
|
||||
{
|
||||
/// <summary>Add a device to a room, creating the room if this is the first arrival.</summary>
|
||||
EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
|
||||
|
||||
/// <summary>Remove whichever device owns this connection, returning the room it left.</summary>
|
||||
(string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
|
||||
|
||||
/// <summary>Snapshot of a room's roster, or null if the room is gone.</summary>
|
||||
EchoRoomSnapshot? Snapshot(string roomCode);
|
||||
|
||||
/// <summary>Room codes with at least two devices, which is the minimum for a measurement.</summary>
|
||||
IReadOnlyCollection<string> MeasurableRooms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Open a new round for a room, rotating which device chirps first so no single device is
|
||||
/// permanently the slot-order anchor.
|
||||
/// </summary>
|
||||
EchoRoundSchedule? StartRound(
|
||||
string roomCode,
|
||||
int slotMilliseconds,
|
||||
int tailMilliseconds,
|
||||
TimeSpan lead,
|
||||
TimeSpan grace);
|
||||
|
||||
/// <summary>File a device's peaks against the room's open round.</summary>
|
||||
bool Report(string connectionId, EchoPeakReport report);
|
||||
|
||||
/// <summary>
|
||||
/// Close the open round if every device has reported or its deadline has passed, returning the
|
||||
/// reports to broadcast.
|
||||
/// </summary>
|
||||
EchoRoundResult? TryCloseRound(string roomCode);
|
||||
|
||||
/// <summary>Drop rooms that have had no activity for longer than <paramref name="idleFor"/>.</summary>
|
||||
int PruneIdle(TimeSpan idleFor);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
|
||||
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
|
||||
/// auto-training games), so every game is reachable from one place for lookup and spectating.
|
||||
/// </summary>
|
||||
public interface IGameStore
|
||||
{
|
||||
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||
void Add(GameState game);
|
||||
|
||||
/// <summary>Look a game up by id.</summary>
|
||||
bool TryGet(Guid id, out GameState game);
|
||||
|
||||
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||
bool Contains(Guid id);
|
||||
|
||||
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||
IReadOnlyCollection<GameState> All { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||
/// </summary>
|
||||
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Thin managed facade over the native learned-weights model. The weights, all feature
|
||||
/// computation, per-game accumulation, the update rule, and persistence live in the native
|
||||
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
|
||||
/// the table back for visualization.
|
||||
/// </summary>
|
||||
public interface ILearnedWeightsStore
|
||||
{
|
||||
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
|
||||
string WeightsFilePath { get; }
|
||||
|
||||
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
|
||||
LearnedWeightsSnapshot Snapshot();
|
||||
|
||||
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
|
||||
nint CreateTrainer();
|
||||
|
||||
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
|
||||
void Record(nint trainer, string fen);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a finished game's outcome to the global weights (and saves): rewards the
|
||||
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
|
||||
/// </summary>
|
||||
void ApplyResult(nint trainer, PieceColor winner, double weight);
|
||||
|
||||
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
|
||||
void DestroyTrainer(nint trainer);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||
public sealed record SelfPlayConfig(
|
||||
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||
ChessEngineKind BlackKind, int BlackSkill);
|
||||
|
||||
/// <summary>
|
||||
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
|
||||
/// the learned engine plays) feeding the result back into the learned weights. Used by the
|
||||
/// spectator "watch" endpoint and by the auto-trainer.
|
||||
/// </summary>
|
||||
public interface ISelfPlayCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Create, register, and start running a self-play game. Returns immediately with the new
|
||||
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
|
||||
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
|
||||
/// </summary>
|
||||
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"BlogApi": {
|
||||
"BaseUrl": "https://media.joshheaps.net",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-index {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-filter strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-filter a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-filter a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.blog-empty {
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-card:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-card-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-card-title {
|
||||
color: var(--color-heading-secondary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.3rem;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-card-link:hover .blog-card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-card-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-card-summary {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-card-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-back {
|
||||
color: var(--color-text-subtle);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-back:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-post-header {
|
||||
margin: 2.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.blog-post-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Post Content ── */
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text);
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.post-content h1,
|
||||
.post-content h2,
|
||||
.post-content h3,
|
||||
.post-content h4 {
|
||||
color: var(--color-heading-secondary);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.post-content h1 { font-size: 1.6rem; }
|
||||
.post-content h2 { font-size: 1.35rem; }
|
||||
.post-content h3 { font-size: 1.15rem; }
|
||||
|
||||
.post-content p {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.post-content a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-content a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.post-content ul,
|
||||
.post-content ol {
|
||||
margin: 0 0 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.post-content li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.post-content code {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-heading-secondary);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-content pre {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.post-content pre[class*="language-"] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.post-content pre code {
|
||||
background: none;
|
||||
color: var(--prism-text);
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.post-content blockquote {
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.post-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.post-content strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.post-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Prism.js Tomorrow Night theme (customized) */
|
||||
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
|
||||
|
||||
/* ============================================
|
||||
Theme colors — edit these to restyle
|
||||
============================================ */
|
||||
:root {
|
||||
--prism-background: #0a0a0a;
|
||||
--prism-text: #909090;
|
||||
--prism-comment: #555;
|
||||
--prism-punctuation: #707070;
|
||||
--prism-tag: #09ff00;
|
||||
--prism-function: #fff;
|
||||
--prism-boolean: #1ab815;
|
||||
--prism-number: #1ab815;
|
||||
--prism-property: #c8c8c8;
|
||||
--prism-class: #c8c8c8;
|
||||
--prism-keyword: #09ff00;
|
||||
--prism-string: #43c760;
|
||||
--prism-variable: #43c760;
|
||||
--prism-operator: #999;
|
||||
--prism-inserted: #09ff00;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: var(--prism-text);
|
||||
background: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: var(--prism-background);
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--prism-comment);
|
||||
}
|
||||
|
||||
/* Punctuation */
|
||||
.token.punctuation {
|
||||
color: var(--prism-punctuation);
|
||||
}
|
||||
|
||||
/* Tags, attributes, deleted */
|
||||
.token.tag,
|
||||
.token.attr-name,
|
||||
.token.namespace,
|
||||
.token.deleted {
|
||||
color: var(--prism-tag);
|
||||
}
|
||||
|
||||
/* Function names */
|
||||
.token.function-name,
|
||||
.token.function {
|
||||
color: var(--prism-function);
|
||||
}
|
||||
|
||||
/* Booleans */
|
||||
.token.boolean {
|
||||
color: var(--prism-boolean);
|
||||
}
|
||||
|
||||
/* Numbers */
|
||||
.token.number {
|
||||
color: var(--prism-number);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.token.property {
|
||||
color: var(--prism-property);
|
||||
}
|
||||
|
||||
/* Classes, constants, symbols */
|
||||
.token.class-name,
|
||||
.token.constant,
|
||||
.token.symbol {
|
||||
color: var(--prism-class);
|
||||
}
|
||||
|
||||
/* Keywords, selectors, builtins */
|
||||
.token.selector,
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.keyword,
|
||||
.token.builtin {
|
||||
color: var(--prism-keyword);
|
||||
}
|
||||
|
||||
/* Strings, chars, attr values, regex */
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex {
|
||||
color: var(--prism-string);
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
.token.variable {
|
||||
color: var(--prism-variable);
|
||||
}
|
||||
|
||||
/* Operators, entities, URLs */
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--prism-operator);
|
||||
}
|
||||
|
||||
/* Inserted */
|
||||
.token.inserted {
|
||||
color: var(--prism-inserted);
|
||||
}
|
||||
|
||||
/* Formatting */
|
||||
.token.bold,
|
||||
.token.important {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
#chessBoard {
|
||||
margin: 2vw auto;
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
@@ -158,4 +155,71 @@
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
pointer-events: none; /* ensures img doesn't steal the click */
|
||||
}
|
||||
|
||||
#colorModal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #1e1e1e;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#colorModal p {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#colorButtonContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#colorButtonContainer button {
|
||||
background-color: #2c2c2c;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#colorButtonContainer button:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.chessSquare .coordinate-label {
|
||||
position: absolute;
|
||||
font-size: 1.2vw;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chessSquare.light .coordinate-label {
|
||||
color: #656770;
|
||||
}
|
||||
|
||||
.chessSquare.dark .coordinate-label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.chessSquare .row-label {
|
||||
top: 2px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.chessSquare .col-label {
|
||||
bottom: 2px;
|
||||
right: 4px;
|
||||
}
|
||||
@@ -1,137 +1,429 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
cursor: default;
|
||||
:root {
|
||||
--bg: #2b2c30;
|
||||
--panel: #21232a;
|
||||
--panel-row: rgba(255, 255, 255, 0.035);
|
||||
--bar: #1e2026;
|
||||
--accent: #8cd5ed;
|
||||
--accent-ink: #10222a;
|
||||
--text: #d6d6d6;
|
||||
--muted: #8b8f99;
|
||||
--line: #34373f;
|
||||
--bar-h: clamp(34px, 5.5vh, 50px);
|
||||
--radius: 12px;
|
||||
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
/* The chess page is a fixed, single-screen app: never scroll. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
}
|
||||
/* ---- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Portrait / narrow: board on top, panel stacked below. */
|
||||
#chessContainer {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 5vw;
|
||||
gap: clamp(8px, 1.6vh, 16px);
|
||||
padding: clamp(10px, 2vh, 20px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
width: min(94vw, 56vh);
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 90vw; /* use the smaller of width or height */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 90vw;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
align-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
#gamePanel {
|
||||
width: min(94vw, 56vh);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
flex-direction: column;
|
||||
order: -1;
|
||||
flex-shrink: 1;
|
||||
font-size: large;
|
||||
margin: 5vw;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
flex-grow: 3;
|
||||
}
|
||||
|
||||
/* Put UI to left/right when screen is short */
|
||||
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||
@media (min-aspect-ratio: 1/1) {
|
||||
#chessContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto, auto;
|
||||
grid-template-rows: auto, auto;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
gap: clamp(16px, 3vw, 48px);
|
||||
padding: clamp(12px, 3vh, 28px);
|
||||
}
|
||||
|
||||
.sideContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: min(5vw, 5vh);
|
||||
}
|
||||
|
||||
#boardContainer {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
#textContainer {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
#buttonContainer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
#startGameBtn {
|
||||
padding: 1vw;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
#startCPUGame {
|
||||
padding: 1vw;
|
||||
margin: 1vw;
|
||||
font-size: large;
|
||||
#boardArea {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 1;
|
||||
width: min(90vh, 90vw);
|
||||
max-height: 90vh;
|
||||
width: min(72vh, 54vw);
|
||||
height: min(72vh, 54vw);
|
||||
}
|
||||
|
||||
#difficultyButtonContainer {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
.playerBar {
|
||||
width: min(72vh, 54vw);
|
||||
}
|
||||
}
|
||||
|
||||
#gamePanel {
|
||||
width: clamp(300px, 26vw, 380px);
|
||||
height: min(86vh, 100%);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Player bars ------------------------------------------------------- */
|
||||
|
||||
.playerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: var(--bar-h);
|
||||
padding: 0 12px;
|
||||
background: var(--bar);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.playerDot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerDot.white {
|
||||
background: #ededed;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
|
||||
}
|
||||
|
||||
.playerDot.black {
|
||||
background: #2c2c2c;
|
||||
box-shadow: 0 0 0 1px #565656 inset;
|
||||
}
|
||||
|
||||
.playerName {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capturedTray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capturedPiece {
|
||||
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
|
||||
width: auto;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
.advantage {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Game panel -------------------------------------------------------- */
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelLogo {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panelHeader h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#moveList {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.movePlaceholder {
|
||||
margin: 0;
|
||||
padding: 18px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.moveRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4em 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.moveRow:nth-child(odd) {
|
||||
background: var(--panel-row);
|
||||
}
|
||||
|
||||
.moveNum {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.moveSan {
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.moveSan.latest {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#statusLine {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#statusLine.alert {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#panelButtons {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: clamp(0.92rem, 1vw, 1.05rem);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: clamp(10px, 1.4vh, 14px) 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #363a44;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #424752;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: #2a2e37;
|
||||
}
|
||||
|
||||
#watchLink {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#watchLink:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
|
||||
|
||||
.iconBtn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
|
||||
#mobileBar,
|
||||
#menuBackdrop,
|
||||
#menuClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Phone layout ------------------------------------------------------ */
|
||||
/* The full panel doesn't fit alongside a usable board on a phone, so we show
|
||||
the board + a slim status/menu bar, and tuck the move list and secondary
|
||||
actions into a slide-up sheet. The page itself still never scrolls. */
|
||||
@media (max-width: 640px) {
|
||||
#chessContainer {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#boardArea {
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playerBar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
|
||||
#gamePanel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 85dvh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
z-index: 60;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body.menu-open #gamePanel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The slim bar already shows status, so hide the sheet's inline copy. */
|
||||
#gamePanel #statusLine {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#menuClose {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
body.menu-open #menuBackdrop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mobileBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(94vw, 60vh);
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#mobileStatus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#mobileStatus.alert {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#mobileBar #menuToggle {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchHeader {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 0;
|
||||
}
|
||||
|
||||
#watchHeader h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchStatus {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
#watchControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.enginePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
.enginePicker legend {
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
#watchControls select {
|
||||
background-color: #1e1e1e;
|
||||
color: #d6d6d6;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
#startCpuVsCpu {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#startCpuVsCpu:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToPlay {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#backToPlay:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#gamesFeed {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gameCard {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameResult {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #8cd5ed;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copyPgnBtn {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyPgnBtn:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.fullscreen-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
background-color: #2b2c30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .miniBoard {
|
||||
width: min(90vh, 90vw);
|
||||
height: min(90vh, 90vw);
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .backButton {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameCardHeader {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.miniBoard {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #141414;
|
||||
color: #d6d6d6;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#weightsHeader {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
#weightsHeader h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
#weightsStatus {
|
||||
color: #9a9a9a;
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
#weightsControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.heatLegend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.legendBar {
|
||||
width: 120px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
|
||||
}
|
||||
|
||||
#refreshWeights {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#refreshWeights:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToWatch {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.weightsSection {
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.weightsSection h2 {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
text-align: center;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.weightsGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.featurePanel {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.featureRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.featureLabel {
|
||||
flex: 0 0 6.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #c8c8c8;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.featureTrack {
|
||||
flex: 1 1 auto;
|
||||
height: 14px;
|
||||
background-color: #232323;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featureBar {
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.featureValue {
|
||||
flex: 0 0 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #e8e8e8;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weightBoard {
|
||||
background-color: #1c1c1c;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.weightBoardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weightBoardIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.weightRange {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.miniHeat {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1rem repeat(8, 34px);
|
||||
}
|
||||
|
||||
.heatSquare {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
color: #f0f0f0;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.heatLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
font-size: 0.6rem;
|
||||
color: #777;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
margin: 0;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
#echoHeader {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
#echoHeader h1 {
|
||||
color: var(--color-heading);
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.echo-blurb {
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.echo-privacy {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#echoControls {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 0 auto 1rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
#echoControls label {
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
gap: 0.3rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
#echoControls input {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-heading);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
width: 7rem;
|
||||
}
|
||||
|
||||
#echoControls input:focus {
|
||||
border-color: var(--color-accent-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#echoControls button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
#echoControls button:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
#echoControls button:disabled {
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.echo-status,
|
||||
.echo-share {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 0 auto 0.4rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-share a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#echoWarnings {
|
||||
margin: 0 auto;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-warning {
|
||||
background: rgba(255, 170, 0, 0.08);
|
||||
border: 1px solid rgba(255, 170, 0, 0.35);
|
||||
border-radius: 6px;
|
||||
color: #ffcc66;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
}
|
||||
|
||||
.echo-readout {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 46rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.echo-metres {
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(3.5rem, 14vw, 7rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.echo-metres small {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.28em;
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.echo-readout-detail,
|
||||
.echo-readout-idle {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#echoPanels {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
margin: 0 auto;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.echo-panel-wide {
|
||||
margin: 1rem auto 0;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.echo-panel h2 {
|
||||
color: var(--color-heading-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.echo-hint {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
margin: -0.4rem 0 0.75rem;
|
||||
}
|
||||
|
||||
#echoRoster {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.echo-device {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.echo-device:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.echo-device-self {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
#echoPairs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#echoPairs table {
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#echoPairs th {
|
||||
color: var(--color-text-subtle);
|
||||
font-weight: 500;
|
||||
padding: 0 0.6rem 0.4rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#echoPairs td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-heading-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.4rem 0.6rem 0.4rem 0;
|
||||
}
|
||||
|
||||
.echo-dropped td {
|
||||
color: var(--color-text-subtle);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.echo-slots {
|
||||
list-style: none;
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.echo-slots li {
|
||||
color: var(--color-heading-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.echo-slots .echo-missed {
|
||||
color: #ff7676;
|
||||
}
|
||||
|
||||
.echo-diag-line {
|
||||
color: var(--color-text-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.echo-diag-line strong {
|
||||
color: #ff7676;
|
||||
}
|
||||
|
||||
#echoTrace {
|
||||
display: block;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.echo-back {
|
||||
color: var(--color-text-subtle);
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
margin: 2rem auto 0;
|
||||
max-width: 46rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.echo-back:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* ── Blog Section ── */
|
||||
|
||||
#BlogBox {
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#blogPostsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-teaser-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-item:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-teaser-title {
|
||||
font-size: clamp(1rem, 1.5vw, 1.15rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-title:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-teaser-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.blog-teaser-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-view-all {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-view-all:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
#Contacts {
|
||||
margin-top: 20vw;
|
||||
/* ── Footer / Contacts ── */
|
||||
|
||||
#Contacts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
height: auto;
|
||||
width: 3.5vw;
|
||||
margin-bottom: 5vw;
|
||||
margin-left: 3vw;
|
||||
fill: rgb(212, 212, 212);
|
||||
transition: fill 0.5s;
|
||||
width: clamp(1.25rem, 2.5vw, 1.75rem);
|
||||
fill: var(--color-text-subtle);
|
||||
transition: fill 0.2s;
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
fill: white;
|
||||
transition: fill 0.5s;
|
||||
}
|
||||
.social-link:hover .social-icon {
|
||||
fill: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.diagonal-section-left {
|
||||
position: absolute;
|
||||
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-left:has(.projectImage:hover) {
|
||||
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageLeft {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
}
|
||||
|
||||
.projectTextRight {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 50vw;
|
||||
}
|
||||
|
||||
.projectHeaderRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
}
|
||||
|
||||
.projectDescriptionRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
.diagonal-section-right {
|
||||
position: absolute;
|
||||
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-right:has(.projectImage:hover) {
|
||||
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageRight {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.projectTextLeft {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.projectHeaderLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
|
||||
.projectDescriptionLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
@@ -1,29 +1,90 @@
|
||||
#ProjectsBox {
|
||||
/* ── Projects Section ── */
|
||||
|
||||
#ProjectsBox {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 20vw;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.projectContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* ── Card ── */
|
||||
|
||||
.project-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
transition: border-color 0.25s, transform 0.25s;
|
||||
}
|
||||
|
||||
.displayBox {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
.project-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
a.projectContainer {
|
||||
text-decoration: none;
|
||||
/* ── Image ── */
|
||||
|
||||
.project-image-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projectImage {
|
||||
transition: width 0.5s ease;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.projectImage:hover {
|
||||
width: 40vw;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.project-card:hover .projectImage {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* ── Info ── */
|
||||
|
||||
.project-info {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.project-link {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,134 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
background-color: #2b2c30;
|
||||
color: #09ff00;
|
||||
overflow-x: hidden;
|
||||
cursor: default;
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
|
||||
#Header {
|
||||
margin-top: 2vw;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.headerOption {
|
||||
font: bold 2vw arial;
|
||||
display: inline;
|
||||
margin-left: 10vw;
|
||||
.site-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-subtle);
|
||||
cursor: pointer;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
transition: color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.headerOption:hover {
|
||||
font: bold 2.3vw arial;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
.nav-link:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Hero / Welcome Message ── */
|
||||
|
||||
#Hero {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
|
||||
}
|
||||
|
||||
#WelcomeMessage {
|
||||
font: 3vw arial;
|
||||
width: auto;
|
||||
height: 40vw;
|
||||
margin-top: 15vw;
|
||||
text-align: left;
|
||||
padding-left: 10vw;
|
||||
padding-right: 10vw;
|
||||
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
|
||||
max-width: 720px;
|
||||
min-height: clamp(12rem, 28vw, 20rem);
|
||||
line-height: 1.7;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Section Titles ── */
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Demos ── */
|
||||
|
||||
#DemosBox {
|
||||
margin: 10vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
width: 80%;
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#DemoHeader {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
#buttonWrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.demoButton {
|
||||
margin-top: 5vw;
|
||||
margin-right: 2vw;
|
||||
padding-top: 0.5vw;
|
||||
padding-bottom: 0.5vw;
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-radius: 5vw;
|
||||
width: 20vw;
|
||||
height: 8vw;
|
||||
font-size: 2vw;
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.demoButton:hover {
|
||||
background: var(--color-surface-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
#MoreFiller {
|
||||
cursor: default;
|
||||
}
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
#MoreFiller:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#Header {
|
||||
padding: 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user