Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8b3e8447 |
@@ -4,8 +4,9 @@
|
|||||||
"Bash(dotnet test:*)",
|
"Bash(dotnet test:*)",
|
||||||
"Bash(dotnet build)",
|
"Bash(dotnet build)",
|
||||||
"Bash(dir:*)",
|
"Bash(dir:*)",
|
||||||
"Bash(dotnet add:*)",
|
"Bash(node --check:*)",
|
||||||
"Bash(dotnet build:*)"
|
"Bash(cat:*)",
|
||||||
|
"Bash(node build.js:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
|
runs-on: ubuntu-latest
|
||||||
# compiled libchess_engine.so loads there. Keep this == the server's release.
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -26,16 +24,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x' # adjust if needed
|
dotnet-version: '8.0.x' # adjust if needed
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build native chess engine (libchess_engine.so)
|
|
||||||
run: |
|
|
||||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
|
||||||
cmake --build native/chess_engine/build
|
|
||||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
run: dotnet publish -c Release -o ./publish
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -54,12 +45,6 @@ jobs:
|
|||||||
name: site-publish
|
name: site-publish
|
||||||
path: publish
|
path: publish
|
||||||
|
|
||||||
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
|
||||||
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
|
||||||
# carries it to the server, where the service user can run it regardless of file owner.
|
|
||||||
- name: Restore Stockfish executable bit
|
|
||||||
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
|
||||||
|
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
install -m 700 -d ~/.ssh
|
install -m 700 -d ~/.ssh
|
||||||
@@ -69,9 +54,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Rsync to server
|
- name: Rsync to server
|
||||||
run: |
|
run: |
|
||||||
# --exclude chess-data: never let --delete remove the learned-engine training
|
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||||
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
|
||||||
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
|
||||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||||
|
|
||||||
- name: Reload and restart service
|
- name: Reload and restart service
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore JoshHeaps.Net.slnf
|
run: dotnet restore
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
run: dotnet build --no-restore
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ jobs:
|
|||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore JoshHeaps.Net.slnf
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
run: dotnet build --no-restore
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
# Learned chess engine weights (runtime training output, not source)
|
|
||||||
chess-data/
|
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
@@ -364,6 +361,3 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
|
||||||
native/**/build/
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
+2
-36
@@ -1,60 +1,26 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 18
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 18.6.11822.322 stable
|
VisualStudioVersion = 17.9.34728.123
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
|
|
||||||
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"solution": {
|
|
||||||
"path": "JoshHeaps.Net.sln",
|
|
||||||
"projects": [
|
|
||||||
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
|
||||||
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# .gitea/workflows/deploy.yml
|
|
||||||
name: Build and Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-${{ gitea.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install deploy tools
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends rsync openssh-client
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '8.0.x'
|
|
||||||
|
|
||||||
- name: Restore
|
|
||||||
run: dotnet restore
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
run: dotnet publish -c Release -o ./publish
|
|
||||||
|
|
||||||
- name: Prepare SSH
|
|
||||||
env:
|
|
||||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PORT="${SSH_PORT:-22}"
|
|
||||||
install -m 700 -d ~/.ssh
|
|
||||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
- name: Rsync to server
|
|
||||||
env:
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
TARGET_DIR: ${{ secrets.TARGET_DIR }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PORT="${SSH_PORT:-22}"
|
|
||||||
rsync -az --delete \
|
|
||||||
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
|
||||||
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
|
||||||
|
|
||||||
- name: Restart service
|
|
||||||
env:
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PORT="${SSH_PORT:-22}"
|
|
||||||
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
|
|
||||||
"sudo systemctl daemon-reload \
|
|
||||||
&& sudo systemctl restart '$SERVICE_NAME' \
|
|
||||||
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
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!
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpPost("invalidate")]
|
|
||||||
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
|
|
||||||
{
|
|
||||||
var expectedKey = configuration["BlogApi:InvalidateKey"];
|
|
||||||
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
blogService.ClearCache();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
namespace JoshHeaps.Net.Controllers;
|
||||||
|
|
||||||
@@ -11,15 +11,16 @@ namespace JoshHeaps.Net.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ChessController(
|
public class ChessController(
|
||||||
IChessService chessService,
|
IChessService chessService,
|
||||||
IBackgroundTaskQueue queue,
|
IHubContext<ChessHub> chessHub,
|
||||||
IChessEngineFactory engineFactory,
|
IBackgroundTaskQueue queue) : ControllerBase
|
||||||
IComputerMoveOrchestrator orchestrator,
|
|
||||||
ILearnedWeightsStore weightsStore,
|
|
||||||
IGameStore gameStore,
|
|
||||||
ISelfPlayCoordinator selfPlay,
|
|
||||||
AutoTrainingSettings autoTraining,
|
|
||||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Store of ongoing games.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||||
|
|
||||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||||
@@ -29,45 +30,38 @@ public class ChessController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("new")]
|
[HttpGet("new")]
|
||||||
[HttpGet("new/{difficulty}")]
|
[HttpGet("new/{difficulty}")]
|
||||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
public ActionResult CreateGame(int difficulty = 20)
|
||||||
{
|
{
|
||||||
var gameState = chessService.CreateNewGame();
|
var gameState = chessService.CreateNewGame();
|
||||||
gameStore.Add(gameState);
|
_games[gameState.GameId] = gameState;
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
gameState.IsVsComputer = true;
|
||||||
gameState.WhiteJoined = true;
|
gameState.WhiteJoined = true;
|
||||||
gameState.BlackJoined = true;
|
gameState.BlackJoined = true;
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
Guid computerId = Guid.NewGuid();
|
Guid computerId = Guid.NewGuid();
|
||||||
var isWhite = color.ToLowerInvariant() switch
|
var isWhite = Random.Shared.Next(2) == 0;
|
||||||
{
|
|
||||||
"white" => true,
|
|
||||||
"black" => false,
|
|
||||||
_ => Random.Shared.Next(2) == 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
var computer = engineFactory.Create(difficulty);
|
gameState.Computer = new(difficulty);
|
||||||
|
|
||||||
if (isWhite)
|
if (isWhite)
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = playerId;
|
gameState.WhitePlayerId = playerId;
|
||||||
gameState.BlackPlayerId = computerId;
|
gameState.BlackPlayerId = computerId;
|
||||||
gameState.BlackComputer = computer;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = computerId;
|
gameState.WhitePlayerId = computerId;
|
||||||
gameState.BlackPlayerId = playerId;
|
gameState.BlackPlayerId = playerId;
|
||||||
gameState.WhiteComputer = computer;
|
|
||||||
queue.Queue(async () =>
|
queue.Queue(async () =>
|
||||||
{
|
{
|
||||||
// Give user's browser time to connect to signalR and such.
|
// Give user's browser time to connect to signalR and such.
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||||
await orchestrator.PlayAsync(gameState);
|
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -77,55 +71,6 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
|
||||||
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
|
||||||
/// chosen independently; when the learned engine plays, the game also trains it.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("watch/cpu")]
|
|
||||||
[HttpGet("watch/cpu/{difficulty}")]
|
|
||||||
public ActionResult CreateSelfPlayGame(
|
|
||||||
int difficulty = 4,
|
|
||||||
string whiteEngine = "custom",
|
|
||||||
string blackEngine = "custom",
|
|
||||||
int? whiteSkill = null,
|
|
||||||
int? blackSkill = null)
|
|
||||||
{
|
|
||||||
var config = new SelfPlayConfig(
|
|
||||||
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
|
||||||
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
|
||||||
|
|
||||||
var (gameId, _) = selfPlay.StartGame(config);
|
|
||||||
|
|
||||||
return Ok(new { GameId = gameId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"stockfish" => ChessEngineKind.Stockfish,
|
|
||||||
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
|
||||||
_ => ChessEngineKind.Custom
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
|
||||||
/// itself runs only outside Development; this reflects the target the service is keeping.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("autotrain")]
|
|
||||||
public ActionResult GetAutoTrain() =>
|
|
||||||
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
|
||||||
/// Takes effect live — the background service tops up or drains toward the new count.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("autotrain")]
|
|
||||||
public ActionResult SetAutoTrain([FromQuery] int count)
|
|
||||||
{
|
|
||||||
autoTraining.GameCount = count; // clamped inside the setter
|
|
||||||
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins the "pool" of chess players.
|
/// Joins the "pool" of chess players.
|
||||||
/// Test code expects to receive a GUID for the player
|
/// Test code expects to receive a GUID for the player
|
||||||
@@ -135,12 +80,12 @@ public class ChessController(
|
|||||||
public ActionResult JoinGame()
|
public ActionResult JoinGame()
|
||||||
{
|
{
|
||||||
Console.WriteLine("joining game");
|
Console.WriteLine("joining game");
|
||||||
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
||||||
|
|
||||||
if (gameState == null)
|
if (gameState == null)
|
||||||
{
|
{
|
||||||
gameState = chessService.CreateNewGame();
|
gameState = chessService.CreateNewGame();
|
||||||
gameStore.Add(gameState);
|
_games[gameState.GameId] = gameState;
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
@@ -158,7 +103,7 @@ public class ChessController(
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -168,62 +113,41 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("active")]
|
|
||||||
public ActionResult GetActiveGames()
|
|
||||||
{
|
|
||||||
var activeGames = gameStore.All
|
|
||||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
|
||||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
|
||||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
|
||||||
.Select(g => new
|
|
||||||
{
|
|
||||||
g.GameId,
|
|
||||||
g.IsVsComputer,
|
|
||||||
g.IsComputerVsComputer,
|
|
||||||
WhiteEngine = g.WhiteEngineKind.ToString(),
|
|
||||||
BlackEngine = g.BlackEngineKind.ToString(),
|
|
||||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
|
||||||
MoveCount = g.MoveHistory.Count,
|
|
||||||
g.IsCheck
|
|
||||||
})
|
|
||||||
.OrderByDescending(g => g.MoveCount);
|
|
||||||
|
|
||||||
return Ok(activeGames);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
|
||||||
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("weights")]
|
|
||||||
public ActionResult GetLearnedWeights()
|
|
||||||
{
|
|
||||||
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
|
||||||
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
|
||||||
|
|
||||||
var snapshot = weightsStore.Snapshot();
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
|
||||||
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
|
||||||
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the state of an existing game by ID.
|
/// Get the state of an existing game by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("{gameId}")]
|
[HttpGet("{gameId}")]
|
||||||
public ActionResult GetGameState(Guid gameId)
|
public ActionResult GetGameState(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!gameStore.TryGet(gameId, out var gameState))
|
if (!_games.TryGetValue(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
return Ok(gameState.ToDto());
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -231,9 +155,9 @@ public class ChessController(
|
|||||||
/// The test passes a JSON body with a MoveDto.
|
/// The test passes a JSON body with a MoveDto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("move")]
|
[HttpPost("move")]
|
||||||
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
||||||
{
|
{
|
||||||
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
// Check if player is authorized to move
|
// Check if player is authorized to move
|
||||||
@@ -257,73 +181,17 @@ public class ChessController(
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return BadRequest(result);
|
return BadRequest(result);
|
||||||
|
|
||||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
if (result.IsCheckmate || result.IsStalemate)
|
||||||
|
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||||
if (isGameOver)
|
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
|
||||||
else if (gameState.IsVsComputer)
|
else if (gameState.IsVsComputer)
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
else
|
else
|
||||||
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
var state = gameState.ToDto();
|
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||||
|
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||||
|
|
||||||
// Broadcast the move (with the full resulting state) to everyone watching this
|
return Ok(result);
|
||||||
// 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>
|
/// <summary>
|
||||||
@@ -332,7 +200,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||||
{
|
{
|
||||||
if (!gameStore.TryGet(gameId, out var gameState))
|
if (!_games.TryGetValue(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||||
@@ -346,7 +214,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves")]
|
[HttpGet("{gameId}/legalMoves")]
|
||||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!gameStore.TryGet(gameId, out var gameState))
|
if (!_games.TryGetValue(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||||
@@ -358,4 +226,39 @@ public class ChessController(
|
|||||||
|
|
||||||
return Ok(allMoves);
|
return Ok(allMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||||
|
{
|
||||||
|
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||||
|
{
|
||||||
|
oldCts.Cancel();
|
||||||
|
oldCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_gameRemovalCancellationTokens[id] = cts;
|
||||||
|
|
||||||
|
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cts.Token);
|
||||||
|
|
||||||
|
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
||||||
|
await game.Computer.DisposeAsync();
|
||||||
|
|
||||||
|
_games.Remove(id, out _);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||||
|
{
|
||||||
|
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
cts.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.SignalR;
|
using JoshHeaps.Net.Models;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Hubs;
|
namespace JoshHeaps.Net.Hubs;
|
||||||
|
|
||||||
@@ -10,6 +11,11 @@ public class ChessHub : Hub
|
|||||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
||||||
|
{
|
||||||
|
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LeaveWebsocketGroup(string gameId)
|
public async Task LeaveWebsocketGroup(string gameId)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -29,16 +28,4 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
|
|
||||||
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
|
|
||||||
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
|
|
||||||
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
|
|
||||||
managed reference to the native DLL. -->
|
|
||||||
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
|
|
||||||
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
|
|
||||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
|
||||||
<Private>false</Private>
|
|
||||||
</ProjectReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace JoshHeaps.Net.Models;
|
|
||||||
|
|
||||||
public class ForfeitDto
|
|
||||||
{
|
|
||||||
public Guid GameId { get; set; }
|
|
||||||
public Guid PlayerId { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Models;
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
@@ -29,25 +28,10 @@ public class GameState
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { get; set; }
|
public bool IsStalemate { get; set; }
|
||||||
public bool IsThreefoldRepetition { get; set; }
|
|
||||||
public bool IsForfeited { get; set; } = false;
|
|
||||||
|
|
||||||
// The color that won, when the game ended by forfeit (null while the game is live).
|
|
||||||
public PieceColor? Winner { get; set; }
|
|
||||||
|
|
||||||
// Keep a history of moves if desired
|
// Keep a history of moves if desired
|
||||||
public List<string> MoveHistory { get; set; }
|
public List<string> MoveHistory { get; set; }
|
||||||
|
|
||||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
|
||||||
public List<string> PositionHistory { get; set; }
|
|
||||||
|
|
||||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
|
||||||
public List<string> SanHistory { get; set; }
|
|
||||||
|
|
||||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
|
||||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
|
||||||
public int HalfmoveClock { get; set; }
|
|
||||||
|
|
||||||
// A list of all pieces to quickly reference them (optional but convenient).
|
// A list of all pieces to quickly reference them (optional but convenient).
|
||||||
// Alternatively, you can iterate the Board array.
|
// Alternatively, you can iterate the Board array.
|
||||||
public List<ChessPiece> Pieces { get; set; }
|
public List<ChessPiece> Pieces { get; set; }
|
||||||
@@ -58,22 +42,8 @@ public class GameState
|
|||||||
public Guid BlackPlayerId { get; set; }
|
public Guid BlackPlayerId { get; set; }
|
||||||
|
|
||||||
public bool IsVsComputer { get; set; } = false;
|
public bool IsVsComputer { get; set; } = false;
|
||||||
public bool IsComputerVsComputer { get; set; } = false;
|
|
||||||
|
|
||||||
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
public Stockfish? Computer { get; set; }
|
||||||
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
|
||||||
public IChessEngine? WhiteComputer { get; set; }
|
|
||||||
public IChessEngine? BlackComputer { get; set; }
|
|
||||||
|
|
||||||
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
|
||||||
// are the learning engine, and lets spectators see who is playing.
|
|
||||||
public ChessEngineKind WhiteEngineKind { get; set; }
|
|
||||||
public ChessEngineKind BlackEngineKind { get; set; }
|
|
||||||
|
|
||||||
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
|
||||||
// learned engine). The engine records each played position into it and applies the
|
|
||||||
// result on game over.
|
|
||||||
public nint Trainer { get; set; }
|
|
||||||
|
|
||||||
// optional: convenience
|
// optional: convenience
|
||||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||||
@@ -84,7 +54,5 @@ public class GameState
|
|||||||
Board = new ChessPiece[8, 8];
|
Board = new ChessPiece[8, 8];
|
||||||
Pieces = [];
|
Pieces = [];
|
||||||
MoveHistory = [];
|
MoveHistory = [];
|
||||||
PositionHistory = [];
|
|
||||||
SanHistory = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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,5 +7,4 @@ public class MoveResultDto
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { get; set; }
|
public bool IsStalemate { get; set; }
|
||||||
public bool IsThreefoldRepetition { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
@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" />
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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,14 +6,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div id="chessContainer">
|
<div id="chessContainer">
|
||||||
<section id="boardArea">
|
<div id="boardContainer">
|
||||||
<div class="playerBar" id="topPlayerBar">
|
|
||||||
<span class="playerDot black"></span>
|
|
||||||
<span class="playerName">Opponent</span>
|
|
||||||
<div class="capturedTray" id="captured-top"></div>
|
|
||||||
<span class="advantage" id="advantage-top"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chessBoard">
|
<div id="chessBoard">
|
||||||
<!-- Placeholder squares -->
|
<!-- Placeholder squares -->
|
||||||
@for (int i = 0; i < 64; i++)
|
@for (int i = 0; i < 64; i++)
|
||||||
@@ -21,41 +14,16 @@
|
|||||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div 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>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside id="gamePanel">
|
<div id="textContainer" class="sideContent">
|
||||||
<header class="panelHeader">
|
|
||||||
<span class="panelLogo">♞</span>
|
|
||||||
<h1>Chess</h1>
|
<h1>Chess</h1>
|
||||||
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
<p>Click a button to start a game :)</p>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="moveList">
|
|
||||||
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="statusLine">Start a game to play.</div>
|
<div id="buttonContainer" class="sideContent">
|
||||||
|
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||||
<div id="panelButtons">
|
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,31 +57,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="colorModal" style="display: none;">
|
|
||||||
<p>Play as:</p>
|
|
||||||
<div id="colorButtonContainer">
|
|
||||||
<button onclick="selectColor('white')">White</button>
|
|
||||||
<button onclick="selectColor('black')">Black</button>
|
|
||||||
<button onclick="selectColor('random')">Random</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/signalr/signalr.min.js"></script>
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||||
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||||
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||||
}
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
@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"]" />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Pages
|
|
||||||
{
|
|
||||||
public class EchoModel : PageModel
|
|
||||||
{
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +1,115 @@
|
|||||||
@page
|
@page
|
||||||
@model JoshHeaps.Net.Pages.IndexModel
|
@model JoshHeaps.Net.Pages.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "JoshHeaps.Net";
|
ViewData["Title"] = "JoshHeaps.Net";
|
||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
<nav id="Header">
|
<header id="Header">
|
||||||
<span class="site-name">josh heaps</span>
|
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
|
||||||
<div class="nav-links">
|
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
|
||||||
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
|
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
|
||||||
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
|
</header>
|
||||||
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
|
|
||||||
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section id="Hero">
|
|
||||||
<div id="WelcomeMessage"></div>
|
<div id="WelcomeMessage"></div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="ProjectsBox">
|
<div id="ProjectsBox">
|
||||||
<h2 class="section-title">Projects</h2>
|
<div id="ChessProject" class="projectContainer">
|
||||||
<div class="projects-grid">
|
<div class="displayBox diagonal-section-left">
|
||||||
<article class="project-card">
|
<a target="_blank" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml">
|
||||||
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
|
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
||||||
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
|
|
||||||
</a>
|
</a>
|
||||||
<div class="project-info">
|
|
||||||
<h3 class="project-title">Chess</h3>
|
|
||||||
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
|
|
||||||
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub →</a>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<div id="ChessText" class="projectTextRight">
|
||||||
|
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
||||||
|
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
||||||
|
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<article class="project-card">
|
<div id="CompilerProject" class="projectContainer">
|
||||||
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
|
<div id="CompilerText" class="projectTextLeft">
|
||||||
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
|
<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"/>
|
||||||
</a>
|
</a>
|
||||||
<div class="project-info">
|
|
||||||
<h3 class="project-title">Compiler</h3>
|
|
||||||
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
|
|
||||||
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub →</a>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
|
||||||
<article class="project-card">
|
<div id="CloudStorageProject" class="projectContainer">
|
||||||
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
|
<div class="displayBox diagonal-section-left">
|
||||||
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
|
<a target="_blank" href="https://media.joshheaps.net">
|
||||||
|
<img id="CloudStorageImage" class="projectImage projectImageLeft" src="/images/CloudStorageDemo.png" title="CloudStorageScreenshot" />
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</article>
|
<div id="CloudStorageText" class="projectTextRight">
|
||||||
|
<h2 id="CloudStorageTitle" class="projectHeaderRight projectHeader">Image Cloud Storage</h2>
|
||||||
|
<p id="CloudStorageDescription" class="projectDescriptionRight projectDescription">
|
||||||
|
My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="DemosBox">
|
<div id="DemosBox" class="displayBox">
|
||||||
<h2 class="section-title">Demos</h2>
|
<h2 id="DemoHeader">Demos</h2>
|
||||||
<div id="buttonWrapper">
|
<div id="buttonWrapper">
|
||||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
||||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
Play Chess
|
||||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'">
|
||||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
Particle Simulator
|
||||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
</button>
|
||||||
|
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
|
||||||
|
Memory Lane
|
||||||
|
</button>
|
||||||
|
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
|
||||||
|
Cloud Image Storage
|
||||||
|
</button>
|
||||||
|
<button id="MoreFiller" class="demoButton">
|
||||||
|
More coming soon...
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="BlogBox">
|
|
||||||
<h2 class="section-title">Latest Posts</h2>
|
|
||||||
<div id="blogPostsList">
|
|
||||||
@foreach (var post in Model.LatestPosts)
|
|
||||||
{
|
|
||||||
<div class="blog-teaser-item">
|
|
||||||
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
|
|
||||||
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
|
|
||||||
<p class="blog-teaser-summary">@post.Summary</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<a class="blog-view-all" href="/blog">View all posts →</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer id="Contacts">
|
<footer id="Contacts">
|
||||||
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
|
||||||
<svg viewBox="0 0 16 16" class="social-icon">
|
<svg viewBox="0 0 16 16" class="social-icon">
|
||||||
|
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||||
</svg>
|
<rect width="100%" height="100%" fill="transparent" />
|
||||||
</a>
|
</a>
|
||||||
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
</svg>
|
||||||
|
|
||||||
<svg viewBox="0 0 24 24" class="social-icon">
|
<svg viewBox="0 0 24 24" class="social-icon">
|
||||||
|
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||||
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
|
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
|
||||||
</svg>
|
<rect width="100%" height="100%" fill="transparent" />
|
||||||
</a>
|
</a>
|
||||||
<a class="social-link" href="mailto:[email protected]">
|
</svg>
|
||||||
|
|
||||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||||
|
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
|
||||||
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
|
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
|
||||||
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
|
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
|
||||||
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
|
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
|
||||||
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
|
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
|
||||||
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
|
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
|
||||||
</svg>
|
<rect width="100%" height="100%" fill="transparent" />
|
||||||
</a>
|
</a>
|
||||||
|
</svg>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
|
||||||
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
<link href="/css/home/blog.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/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
using JoshHeaps.Net.Models;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Pages
|
namespace JoshHeaps.Net.Pages
|
||||||
{
|
{
|
||||||
public class IndexModel(IBlogService blogService) : PageModel
|
public class IndexModel() : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
{
|
{
|
||||||
public List<BlogPost> LatestPosts { get; set; } = [];
|
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
|
||||||
{
|
|
||||||
var allPosts = await blogService.GetAllPostsAsync();
|
|
||||||
LatestPosts = allPosts.Take(3).ToList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
@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"]" />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Pages
|
|
||||||
{
|
|
||||||
public class WatchModel : PageModel
|
|
||||||
{
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
@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"]" />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Pages
|
|
||||||
{
|
|
||||||
public class WeightsModel : PageModel
|
|
||||||
{
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@{
|
@{
|
||||||
Layout = null;
|
Layout = null;
|
||||||
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
|
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
|
using JoshHeaps.Net.Services;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -13,40 +13,12 @@ builder.Services.AddControllers();
|
|||||||
|
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
builder.Services.AddHttpClient("BlogApi", client =>
|
|
||||||
{
|
|
||||||
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
|
|
||||||
client.BaseAddress = new Uri(baseUrl);
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
});
|
|
||||||
builder.Services.AddSingleton<IBlogService, BlogService>();
|
|
||||||
builder.Services.AddSingleton<IChessService, ChessService>();
|
builder.Services.AddSingleton<IChessService, ChessService>();
|
||||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||||
|
|
||||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
|
||||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
|
||||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
|
||||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
|
||||||
builder.Services.AddSingleton<IGameStore, GameStore>();
|
|
||||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
|
||||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
|
||||||
|
|
||||||
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())
|
if (!builder.Environment.IsDevelopment())
|
||||||
{
|
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
|
|
||||||
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
|
||||||
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
|
||||||
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
|
||||||
builder.Services.AddHostedService<AutoTrainingService>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
@@ -79,6 +51,7 @@ app.MapControllers();
|
|||||||
|
|
||||||
app.MapHub<ChessHub>("/chessHub");
|
app.MapHub<ChessHub>("/chessHub");
|
||||||
|
|
||||||
app.MapHub<EchoHub>("/echoHub");
|
// Build particle simulator bundle from ES6 modules
|
||||||
|
ParticleBundler.BuildBundle(app.Environment);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"http": {
|
"http": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"nativeDebugging": true,
|
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "http://localhost:5200",
|
"applicationUrl": "http://localhost:5200",
|
||||||
@@ -21,7 +20,6 @@
|
|||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"nativeDebugging": true,
|
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,73 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
|
||||||
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
|
||||||
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AutoTrainingSettings
|
|
||||||
{
|
|
||||||
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
|
||||||
public const int MaxGames = 16;
|
|
||||||
|
|
||||||
private int _gameCount;
|
|
||||||
|
|
||||||
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
|
||||||
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
|
||||||
/// Reads/writes are atomic; the background service reads this every cycle.
|
|
||||||
/// </summary>
|
|
||||||
public int GameCount
|
|
||||||
{
|
|
||||||
get => Volatile.Read(ref _gameCount);
|
|
||||||
set => Volatile.Write(ref _gameCount, Clamp(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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}'.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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,14 +22,12 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
gameState.Pieces.Clear();
|
gameState.Pieces.Clear();
|
||||||
gameState.MoveHistory.Clear();
|
gameState.MoveHistory.Clear();
|
||||||
gameState.SanHistory.Clear();
|
|
||||||
|
|
||||||
gameState.WhiteCanCastleKingside = true;
|
gameState.WhiteCanCastleKingside = true;
|
||||||
gameState.WhiteCanCastleQueenside = true;
|
gameState.WhiteCanCastleQueenside = true;
|
||||||
gameState.BlackCanCastleKingside = true;
|
gameState.BlackCanCastleKingside = true;
|
||||||
gameState.BlackCanCastleQueenside = true;
|
gameState.BlackCanCastleQueenside = true;
|
||||||
gameState.EnPassantTarget = null;
|
gameState.EnPassantTarget = null;
|
||||||
gameState.HalfmoveClock = 0;
|
|
||||||
|
|
||||||
SetupBlackPieces(gameState);
|
SetupBlackPieces(gameState);
|
||||||
SetupWhitePieces(gameState);
|
SetupWhitePieces(gameState);
|
||||||
@@ -37,9 +35,6 @@ public class ChessService : IChessService
|
|||||||
gameState.CurrentPlayer = PieceColor.White;
|
gameState.CurrentPlayer = PieceColor.White;
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
gameState.PositionHistory.Clear();
|
|
||||||
gameState.PositionHistory.Add(PositionKey(gameState));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetupBlackPieces(GameState gs)
|
private static void SetupBlackPieces(GameState gs)
|
||||||
@@ -142,23 +137,12 @@ public class ChessService : IChessService
|
|||||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||||
|
|
||||||
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
|
||||||
// the check/mate suffix is appended after UpdateCheckStatus.
|
|
||||||
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
|
||||||
|
|
||||||
PerformMove(gameState, piece, targetPos, moveDto);
|
PerformMove(gameState, piece, targetPos, moveDto);
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||||
gameState.MoveHistory.Add(notation);
|
gameState.MoveHistory.Add(notation);
|
||||||
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
|
||||||
|
|
||||||
var positionKey = PositionKey(gameState);
|
|
||||||
gameState.PositionHistory.Add(positionKey);
|
|
||||||
|
|
||||||
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
|
||||||
gameState.IsThreefoldRepetition = true;
|
|
||||||
|
|
||||||
return new MoveResultDto
|
return new MoveResultDto
|
||||||
{
|
{
|
||||||
@@ -166,95 +150,14 @@ public class ChessService : IChessService
|
|||||||
Message = "Move successful.",
|
Message = "Move successful.",
|
||||||
IsCheck = gameState.IsCheck,
|
IsCheck = gameState.IsCheck,
|
||||||
IsCheckmate = gameState.IsCheckmate,
|
IsCheckmate = gameState.IsCheckmate,
|
||||||
IsStalemate = gameState.IsStalemate,
|
IsStalemate = gameState.IsStalemate
|
||||||
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
|
||||||
/// applied (the check/mate suffix is added by the caller afterward).
|
|
||||||
/// </summary>
|
|
||||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
|
||||||
{
|
|
||||||
var from = piece.Position;
|
|
||||||
|
|
||||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
|
||||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
|
||||||
|
|
||||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
|
||||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
|
||||||
bool isCapture = targetOccupied || isEnPassant;
|
|
||||||
string dest = SquareName(target);
|
|
||||||
|
|
||||||
if (piece.Type == PieceType.Pawn)
|
|
||||||
{
|
|
||||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
|
||||||
|
|
||||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
|
||||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
|
||||||
|
|
||||||
if (promotes)
|
|
||||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
|
||||||
|
|
||||||
return san;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
|
||||||
/// target, qualify the origin by file, else rank, else both.
|
|
||||||
/// </summary>
|
|
||||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
|
||||||
{
|
|
||||||
var rivals = gs.Pieces
|
|
||||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
|
||||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (rivals.Count == 0)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
|
||||||
return FileChar(piece.Position.Col).ToString();
|
|
||||||
|
|
||||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
|
||||||
return (8 - piece.Position.Row).ToString();
|
|
||||||
|
|
||||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static char FileChar(int col) => (char)('a' + col);
|
|
||||||
|
|
||||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
|
||||||
|
|
||||||
private static string PieceLetter(PieceType type) => type switch
|
|
||||||
{
|
|
||||||
PieceType.Knight => "N",
|
|
||||||
PieceType.Bishop => "B",
|
|
||||||
PieceType.Rook => "R",
|
|
||||||
PieceType.Queen => "Q",
|
|
||||||
PieceType.King => "K",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
|
||||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
|
||||||
/// </summary>
|
|
||||||
private static string PositionKey(GameState gs)
|
|
||||||
{
|
|
||||||
var fields = gs.ToFen().Split(' ');
|
|
||||||
return string.Join(' ', fields.Take(4));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||||
{
|
{
|
||||||
var oldPos = piece.Position;
|
var oldPos = piece.Position;
|
||||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||||
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
|
||||||
|
|
||||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||||
|
|
||||||
@@ -276,9 +179,6 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
UpdateCastlingRights(gs, piece, oldPos);
|
UpdateCastlingRights(gs, piece, oldPos);
|
||||||
|
|
||||||
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
|
||||||
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
|
||||||
|
|
||||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||||
? PieceColor.Black
|
? PieceColor.Black
|
||||||
: PieceColor.White;
|
: PieceColor.White;
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using JoshHeaps.Net.Models;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
|
||||||
|
|
||||||
public static class PgnExporter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
|
||||||
/// </summary>
|
|
||||||
public static string ToPgn(this GameState gs)
|
|
||||||
{
|
|
||||||
var result = ResultTag(gs);
|
|
||||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
|
||||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
|
||||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
|
||||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
|
||||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
|
||||||
sb.AppendLine($"[Result \"{result}\"]");
|
|
||||||
sb.AppendLine();
|
|
||||||
|
|
||||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
|
||||||
{
|
|
||||||
if (i % 2 == 0)
|
|
||||||
sb.Append(i / 2 + 1).Append(". ");
|
|
||||||
|
|
||||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(result);
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResultTag(GameState gs)
|
|
||||||
{
|
|
||||||
if (gs.IsCheckmate)
|
|
||||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
|
||||||
|
|
||||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
|
||||||
return "1/2-1/2";
|
|
||||||
|
|
||||||
if (gs.IsForfeited)
|
|
||||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
|
||||||
|
|
||||||
return "*";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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,19 +1,22 @@
|
|||||||
|
using JoshHeaps.Net.Hubs;
|
||||||
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
public sealed class Stockfish : IChessEngine
|
public sealed class Stockfish : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly Process _p;
|
private readonly Process _p;
|
||||||
private readonly StreamWriter _stdin;
|
private readonly StreamWriter _stdin;
|
||||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||||
private readonly int _skill;
|
private readonly int _skill;
|
||||||
|
|
||||||
public int Skill => _skill;
|
|
||||||
|
|
||||||
public Stockfish(int skill = 20, int hash = 256)
|
public Stockfish(int skill = 20, int hash = 256)
|
||||||
{
|
{
|
||||||
_skill = skill;
|
_skill = skill;
|
||||||
@@ -74,9 +77,8 @@ public sealed class Stockfish : IChessEngine
|
|||||||
WaitFor("readyok").GetAwaiter().GetResult();
|
WaitFor("readyok").GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
public async Task<string> GetBestMoveAsync(string fen)
|
||||||
{
|
{
|
||||||
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
|
||||||
Send($"position fen {fen}");
|
Send($"position fen {fen}");
|
||||||
Send($"go depth {_skill}");
|
Send($"go depth {_skill}");
|
||||||
string? best = null;
|
string? best = null;
|
||||||
@@ -107,4 +109,145 @@ public sealed class Stockfish : IChessEngine
|
|||||||
await _p.WaitForExitAsync();
|
await _p.WaitForExitAsync();
|
||||||
_p.Dispose();
|
_p.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
|
||||||
|
{
|
||||||
|
var move = await GetBestMoveAsync(state.ToFen());
|
||||||
|
|
||||||
|
var moveDto = move.ToMoveDto(
|
||||||
|
state,
|
||||||
|
state.CurrentPlayer == PieceColor.White
|
||||||
|
? state.WhitePlayerId
|
||||||
|
: state.BlackPlayerId);
|
||||||
|
|
||||||
|
var result = chessService.MakeMove(state, moveDto);
|
||||||
|
|
||||||
|
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StockfishHelpers
|
||||||
|
{
|
||||||
|
public static MoveDto ToMoveDto(
|
||||||
|
this string uci,
|
||||||
|
GameState gameState,
|
||||||
|
Guid playerId)
|
||||||
|
{
|
||||||
|
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||||
|
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||||
|
|
||||||
|
var piece = gameState.Board[fRow, fCol]
|
||||||
|
?? throw new Exception("No piece at source square");
|
||||||
|
|
||||||
|
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||||
|
{
|
||||||
|
'q' => PieceType.Queen,
|
||||||
|
'r' => PieceType.Rook,
|
||||||
|
'b' => PieceType.Bishop,
|
||||||
|
'n' => PieceType.Knight,
|
||||||
|
_ => null
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return new MoveDto
|
||||||
|
{
|
||||||
|
GameId = gameState.GameId,
|
||||||
|
PlayerId = playerId,
|
||||||
|
PieceId = piece.Id,
|
||||||
|
TargetRow = tRow,
|
||||||
|
TargetCol = tCol,
|
||||||
|
PromotionChoice = promo,
|
||||||
|
SourceCol = fCol,
|
||||||
|
SourceRow = fRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||||
|
/// Only piece placement + active colour + castling are computed; the rest use
|
||||||
|
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToFen(this GameState gs)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(64);
|
||||||
|
|
||||||
|
/* 1) piece placement */
|
||||||
|
for (int row = 0; row < 8; row++)
|
||||||
|
{
|
||||||
|
int empty = 0;
|
||||||
|
|
||||||
|
for (int col = 0; col < 8; col++)
|
||||||
|
{
|
||||||
|
var p = gs.Board[row, col];
|
||||||
|
|
||||||
|
if (p is null)
|
||||||
|
{
|
||||||
|
empty++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||||
|
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty > 0) sb.Append(empty);
|
||||||
|
if (row < 7) sb.Append('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2) active colour */
|
||||||
|
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||||
|
|
||||||
|
/* 3) castling rights (from GameState flags) */
|
||||||
|
sb.Append(GetCastlingFlags(gs));
|
||||||
|
|
||||||
|
/* 4) en-passant target square */
|
||||||
|
sb.Append(' ');
|
||||||
|
sb.Append(gs.EnPassantTarget.HasValue
|
||||||
|
? Alg(gs.EnPassantTarget.Value)
|
||||||
|
: "-");
|
||||||
|
|
||||||
|
/* 5-6) half-move clock + full-move number */
|
||||||
|
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||||
|
sb.Append(" 0 ").Append(fullMoves);
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
private static string GetCastlingFlags(GameState gs)
|
||||||
|
{
|
||||||
|
var flags = new StringBuilder(4);
|
||||||
|
|
||||||
|
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||||
|
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||||
|
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||||
|
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||||
|
|
||||||
|
return flags.Length == 0 ? "-" : flags.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Alg(Position p)
|
||||||
|
{
|
||||||
|
char file = (char)('a' + p.Col);
|
||||||
|
int rank = 8 - p.Row;
|
||||||
|
return $"{file}{rank}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static char ToFenChar(ChessPiece p) => p switch
|
||||||
|
{
|
||||||
|
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||||
|
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||||
|
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||||
|
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||||
|
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||||
|
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||||
|
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||||
|
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||||
|
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||||
|
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||||
|
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||||
|
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundles particle simulator ES6 modules into a single file
|
||||||
|
/// </summary>
|
||||||
|
public static class ParticleBundler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Run the particle simulator build script
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="webHostEnvironment">Web host environment for path resolution</param>
|
||||||
|
/// <returns>True if build succeeded, false otherwise</returns>
|
||||||
|
public static bool BuildBundle(IWebHostEnvironment webHostEnvironment)
|
||||||
|
{
|
||||||
|
var particlesPath = Path.Combine(webHostEnvironment.WebRootPath, "js", "particles");
|
||||||
|
var buildScriptPath = Path.Combine(particlesPath, "build.js");
|
||||||
|
|
||||||
|
if (!File.Exists(buildScriptPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"⚠️ Particle bundler script not found at: {buildScriptPath}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine("🔨 Building particle simulator bundle...");
|
||||||
|
|
||||||
|
var processStartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "node",
|
||||||
|
Arguments = $"\"{buildScriptPath}\"",
|
||||||
|
WorkingDirectory = particlesPath,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(processStartInfo);
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("❌ Failed to start Node.js process");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine(output);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("❌ Particle bundle build failed:");
|
||||||
|
Console.WriteLine(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"❌ Error building particle bundle: {ex.Message}");
|
||||||
|
Console.WriteLine(" Make sure Node.js is installed and available in PATH");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,5 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ChessEngine": {
|
|
||||||
"Engine": "Custom"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,5 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*"
|
||||||
"BlogApi": {
|
|
||||||
"BaseUrl": "https://media.joshheaps.net",
|
|
||||||
"InvalidateKey": "CHANGE_ME"
|
|
||||||
},
|
|
||||||
"ChessEngine": {
|
|
||||||
"Engine": "Custom"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
*, *::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);
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
*, *::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;
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/* 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,4 +1,12 @@
|
|||||||
#chessBoard {
|
#boardContainer {
|
||||||
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 2vw auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chessBoard {
|
||||||
|
width: 60vw;
|
||||||
|
height: 60vw;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
grid-template-rows: repeat(8, 1fr);
|
grid-template-rows: repeat(8, 1fr);
|
||||||
@@ -157,47 +165,6 @@
|
|||||||
pointer-events: none; /* ensures img doesn't steal the click */
|
pointer-events: none; /* ensures img doesn't steal the click */
|
||||||
}
|
}
|
||||||
|
|
||||||
#colorModal {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
|
||||||
color: white;
|
|
||||||
z-index: 1000;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorModal p {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorButtonContainer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorButtonContainer button {
|
|
||||||
background-color: #2c2c2c;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorButtonContainer button:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
background-color: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chessSquare .coordinate-label {
|
.chessSquare .coordinate-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 1.2vw;
|
font-size: 1.2vw;
|
||||||
|
|||||||
@@ -1,429 +1,137 @@
|
|||||||
:root {
|
html, body {
|
||||||
--bg: #2b2c30;
|
background-color: #2b2c30;
|
||||||
--panel: #21232a;
|
color: #d6d6d6;
|
||||||
--panel-row: rgba(255, 255, 255, 0.035);
|
cursor: default;
|
||||||
--bar: #1e2026;
|
|
||||||
--accent: #8cd5ed;
|
|
||||||
--accent-ink: #10222a;
|
|
||||||
--text: #d6d6d6;
|
|
||||||
--muted: #8b8f99;
|
|
||||||
--line: #34373f;
|
|
||||||
--bar-h: clamp(34px, 5.5vh, 50px);
|
|
||||||
--radius: 12px;
|
|
||||||
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font);
|
|
||||||
cursor: default;
|
|
||||||
/* The chess page is a fixed, single-screen app: never scroll. */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
#startGameBtn {
|
||||||
height: 100%;
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
order: 1;
|
||||||
|
padding: 2vh;
|
||||||
|
margin: 2vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Layout ------------------------------------------------------------ */
|
#startCPUGame {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
order: 1;
|
||||||
|
padding: 2vh;
|
||||||
|
margin: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* Portrait / narrow: board on top, panel stacked below. */
|
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: clamp(8px, 1.6vh, 16px);
|
justify-content: space-evenly;
|
||||||
padding: clamp(10px, 2vh, 20px);
|
height: 100vh;
|
||||||
overflow: hidden;
|
box-sizing: border-box;
|
||||||
}
|
padding: 5vw;
|
||||||
|
|
||||||
#boardArea {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: min(94vw, 56vh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
border-radius: 6px;
|
width: 90vw; /* use the smaller of width or height */
|
||||||
overflow: hidden;
|
height: auto;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
max-height: 90vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
#gamePanel {
|
#boardContainer {
|
||||||
width: min(94vw, 56vh);
|
align-content: center;
|
||||||
flex: 1 1 auto;
|
flex-grow: 1;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
.sideContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--panel);
|
align-content: center;
|
||||||
border-radius: var(--radius);
|
justify-content: center;
|
||||||
overflow: hidden;
|
text-align: center;
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Landscape / wide: board left, game panel right (chess.com style). */
|
#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 */
|
||||||
@media (min-aspect-ratio: 1/1) {
|
@media (min-aspect-ratio: 1/1) {
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
|
grid-template-columns: auto, auto;
|
||||||
|
grid-template-rows: auto, auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: clamp(16px, 3vw, 48px);
|
align-items: center;
|
||||||
padding: clamp(12px, 3vh, 28px);
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#boardArea {
|
.sideContent {
|
||||||
width: auto;
|
display: flex;
|
||||||
height: 100%;
|
flex-direction: column;
|
||||||
justify-content: center;
|
flex-grow: 1;
|
||||||
flex: 0 0 auto;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
width: min(72vh, 54vw);
|
aspect-ratio: 1 / 1;
|
||||||
height: min(72vh, 54vw);
|
flex-shrink: 1;
|
||||||
|
width: min(90vh, 90vw);
|
||||||
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerBar {
|
#difficultyButtonContainer {
|
||||||
width: min(72vh, 54vw);
|
grid-template-columns: repeat(10, 1fr);
|
||||||
}
|
|
||||||
|
|
||||||
#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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/* ── 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,25 +1,17 @@
|
|||||||
/* ── Footer / Contacts ── */
|
#Contacts {
|
||||||
|
margin-top: 20vw;
|
||||||
#Contacts {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-icon {
|
.social-icon {
|
||||||
height: auto;
|
height: auto;
|
||||||
width: clamp(1.25rem, 2.5vw, 1.75rem);
|
width: 3.5vw;
|
||||||
fill: var(--color-text-subtle);
|
margin-bottom: 5vw;
|
||||||
transition: fill 0.2s;
|
margin-left: 3vw;
|
||||||
|
fill: rgb(212, 212, 212);
|
||||||
|
transition: fill 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-link:hover .social-icon {
|
.social-icon:hover {
|
||||||
fill: var(--color-heading-secondary);
|
fill: white;
|
||||||
|
transition: fill 0.5s;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.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,90 +1,29 @@
|
|||||||
/* ── Projects Section ── */
|
#ProjectsBox {
|
||||||
|
|
||||||
#ProjectsBox {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.projects-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
grid-auto-rows: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 20vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card ── */
|
.projectContainer {
|
||||||
|
position: relative;
|
||||||
.project-card {
|
display: flex;
|
||||||
border: 1px solid var(--color-border);
|
align-items: center;
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-surface);
|
|
||||||
transition: border-color 0.25s, transform 0.25s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card:hover {
|
.displayBox {
|
||||||
border-color: var(--color-border-hover);
|
width: 100vw;
|
||||||
transform: translateY(-2px);
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Image ── */
|
a.projectContainer {
|
||||||
|
text-decoration: none;
|
||||||
.project-image-link {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.projectImage {
|
.projectImage {
|
||||||
width: 100%;
|
transition: width 0.5s ease;
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
transition: transform 0.4s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card:hover .projectImage {
|
.projectImage:hover {
|
||||||
transform: scale(1.03);
|
width: 40vw;
|
||||||
}
|
transition: width 0.5s ease;
|
||||||
|
|
||||||
/* ── Info ── */
|
|
||||||
|
|
||||||
.project-info {
|
|
||||||
padding: 1.25rem 1.5rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-title {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-heading);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-description {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-link {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-link:hover {
|
|
||||||
color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.projects-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-info {
|
|
||||||
padding: 1rem 1.25rem 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,134 +1,67 @@
|
|||||||
*, *::before, *::after {
|
body {
|
||||||
box-sizing: border-box;
|
margin: 0px;
|
||||||
}
|
background-color: #2b2c30;
|
||||||
|
color: #09ff00;
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
font-family: var(--font-body);
|
cursor: default;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navigation ── */
|
|
||||||
|
|
||||||
#Header {
|
#Header {
|
||||||
display: flex;
|
margin-top: 2vw;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem clamp(1.5rem, 5vw, 4rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-name {
|
.headerOption {
|
||||||
font-size: 1.05rem;
|
font: bold 2vw arial;
|
||||||
font-weight: 700;
|
display: inline;
|
||||||
color: var(--color-heading);
|
margin-left: 10vw;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
display: flex;
|
|
||||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
transition: color 0.5s ease, font 0.5s ease;
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.headerOption:hover {
|
||||||
color: var(--color-heading-secondary);
|
font: bold 2.3vw arial;
|
||||||
}
|
transition: color 0.5s ease, font 0.5s ease;
|
||||||
|
|
||||||
/* ── Hero / Welcome Message ── */
|
|
||||||
|
|
||||||
#Hero {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#WelcomeMessage {
|
#WelcomeMessage {
|
||||||
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
|
font: 3vw arial;
|
||||||
max-width: 720px;
|
width: auto;
|
||||||
min-height: clamp(12rem, 28vw, 20rem);
|
height: 40vw;
|
||||||
line-height: 1.7;
|
margin-top: 15vw;
|
||||||
color: var(--color-accent);
|
text-align: left;
|
||||||
font-family: var(--font-mono);
|
padding-left: 10vw;
|
||||||
|
padding-right: 10vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section Titles ── */
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-heading);
|
|
||||||
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Demos ── */
|
|
||||||
|
|
||||||
#DemosBox {
|
#DemosBox {
|
||||||
max-width: 1100px;
|
margin: 10vw;
|
||||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
display: flex;
|
||||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
flex-direction: column;
|
||||||
|
justify-content: end;
|
||||||
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#buttonWrapper {
|
#DemoHeader {
|
||||||
display: grid;
|
margin: 0;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
color: rgb(212, 212, 212);
|
||||||
gap: 0.75rem;
|
font-size: 4vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demoButton {
|
.demoButton {
|
||||||
padding: 0.875rem 1.25rem;
|
margin-top: 5vw;
|
||||||
|
margin-right: 2vw;
|
||||||
|
padding-top: 0.5vw;
|
||||||
|
padding-bottom: 0.5vw;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 5vw;
|
||||||
font-size: 0.875rem;
|
width: 20vw;
|
||||||
font-family: inherit;
|
height: 8vw;
|
||||||
background: var(--color-surface);
|
font-size: 2vw;
|
||||||
color: var(--color-text);
|
background-color: rgb(104, 255, 0, 0.39);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demoButton:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
border-color: var(--color-border-hover);
|
|
||||||
color: var(--color-heading-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#MoreFiller {
|
#MoreFiller {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
#MoreFiller:hover {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
#Header {
|
|
||||||
padding: 1.25rem 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,111 @@
|
|||||||
/* ------- page background + overlays ------- */
|
/* ========================================
|
||||||
|
CSS CUSTOM PROPERTIES
|
||||||
|
======================================== */
|
||||||
|
:root {
|
||||||
|
/* Spacing system */
|
||||||
|
--spacing-xs: 0.35rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 0.6rem;
|
||||||
|
--spacing-lg: 0.75rem;
|
||||||
|
--spacing-xl: 1rem;
|
||||||
|
|
||||||
|
/* Layout constants */
|
||||||
|
--fab-size: 44px;
|
||||||
|
--panel-gap: 12px;
|
||||||
|
--border-radius: 10px;
|
||||||
|
--border-radius-sm: 0.4rem;
|
||||||
|
--border-radius-fab: 8px;
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--bg-primary: #000;
|
||||||
|
--bg-panel: rgba(15, 15, 15, 0.65);
|
||||||
|
--bg-panel-dark: rgba(11, 11, 11, 0.65);
|
||||||
|
--bg-button: #1f1f1f;
|
||||||
|
--bg-button-hover: #2a2a2a;
|
||||||
|
--text-primary: #e6e6e6;
|
||||||
|
--border-color: #2a2a2a;
|
||||||
|
--border-color-dark: #1a1a1a;
|
||||||
|
|
||||||
|
/* Slider colors */
|
||||||
|
--slider-track: #444;
|
||||||
|
--slider-fill: #4cafef;
|
||||||
|
--slider-thumb: #ff5722;
|
||||||
|
|
||||||
|
/* Z-index layers */
|
||||||
|
--z-canvas: 0;
|
||||||
|
--z-ui: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BASE STYLES
|
||||||
|
======================================== */
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #000;
|
background: var(--bg-primary);
|
||||||
color: #e6e6e6;
|
color: var(--text-primary);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* canvas as full-screen background */
|
/* ========================================
|
||||||
|
CANVAS
|
||||||
|
======================================== */
|
||||||
.sim {
|
.sim {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0; /* top:0 right:0 bottom:0 left:0 */
|
inset: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: block;
|
display: block;
|
||||||
z-index: 0;
|
z-index: var(--z-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* floating toolbar */
|
.container {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TOOLBAR
|
||||||
|
======================================== */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: var(--panel-gap);
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
right: 12px;
|
right: var(--panel-gap);
|
||||||
z-index: 20;
|
z-index: var(--z-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: var(--spacing-xl);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .6rem .75rem;
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
background: rgba(15,15,15,.65);
|
background: var(--bg-panel);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: var(--border-radius);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* collapse/expand toolbar */
|
|
||||||
.toolbar .spacer {
|
.toolbar .spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleToolbar {
|
#toggleToolbar {
|
||||||
padding: .35rem .5rem;
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
border-radius: .4rem;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed toolbar state (desktop) */
|
||||||
.toolbar.collapsed {
|
.toolbar.collapsed {
|
||||||
padding: .35rem .5rem;
|
left: var(--panel-gap);
|
||||||
gap: .5rem;
|
right: auto;
|
||||||
|
width: var(--fab-size);
|
||||||
|
height: var(--fab-size);
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.collapsed .group,
|
.toolbar.collapsed .group,
|
||||||
@@ -60,93 +115,150 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* floating rules panel (details) */
|
.toolbar.collapsed #toggleToolbar {
|
||||||
.rulepanel {
|
width: 100%;
|
||||||
position: fixed;
|
height: 100%;
|
||||||
left: 12px;
|
padding: 0;
|
||||||
right: 12px;
|
border-radius: var(--border-radius-fab);
|
||||||
bottom: 12px;
|
font-size: 0;
|
||||||
z-index: 20;
|
|
||||||
background: rgba(11,11,11,.65);
|
|
||||||
border: 1px solid #1a1a1a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
overflow: hidden; /* collapsed state shows only summary */
|
|
||||||
max-height: 42vh; /* when open, content scrolls inside */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel[open] {
|
.toolbar.collapsed #toggleToolbar::before {
|
||||||
|
content: "☰";
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
RULE EDITOR PANEL
|
||||||
|
======================================== */
|
||||||
|
.rulepanel {
|
||||||
|
position: fixed;
|
||||||
|
left: var(--panel-gap);
|
||||||
|
right: var(--panel-gap);
|
||||||
|
bottom: var(--panel-gap);
|
||||||
|
z-index: var(--z-ui);
|
||||||
|
background: var(--bg-panel-dark);
|
||||||
|
border: 1px solid var(--border-color-dark);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
max-height: 42vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rulepanel[open] {
|
||||||
|
max-height: calc(100dvh - 24px - 60px);
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable both-edges;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel[open]::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rulepanel > summary {
|
.rulepanel > summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: .6rem .75rem;
|
border-bottom: 1px solid var(--border-color-dark);
|
||||||
margin: 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: transparent;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
border-bottom: 1px solid #1a1a1a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel > summary::-webkit-details-marker {
|
.rulepanel > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel > summary::after {
|
.rulepanel[open] > summary::after {
|
||||||
content: "▾";
|
content: "▾";
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
opacity: .8;
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed rules panel (FAB style) */
|
||||||
|
.rulepanel:not([open]) {
|
||||||
|
left: var(--panel-gap);
|
||||||
|
right: auto;
|
||||||
|
width: var(--fab-size);
|
||||||
|
height: var(--fab-size);
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel:not([open]) > summary {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary::after {
|
.rulepanel:not([open]) > summary::after {
|
||||||
content: "▸";
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel:not([open]) > summary::before {
|
||||||
|
content: "Rules";
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-body {
|
.rules-body {
|
||||||
padding: .75rem 1rem 1rem;
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-xl);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-grid {
|
.rule-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: .5rem 1rem;
|
gap: var(--spacing-sm) var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-item {
|
.rule-item {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* buttons + inputs (keep your existing styles, just a few tweaks) */
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-import {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
FORM CONTROLS
|
||||||
|
======================================== */
|
||||||
button {
|
button {
|
||||||
background: #1f1f1f;
|
background: var(--bg-button);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-color);
|
||||||
color: #e6e6e6;
|
color: var(--text-primary);
|
||||||
padding: .4rem .7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
border-radius: .5rem;
|
border-radius: var(--spacing-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background: #2a2a2a;
|
background: var(--bg-button-hover);
|
||||||
}
|
|
||||||
|
|
||||||
/* range styling (from your sheet; leave as-is) */
|
|
||||||
:root {
|
|
||||||
--slider-track: #444;
|
|
||||||
--slider-fill: #4cafef;
|
|
||||||
--slider-thumb: #ff5722;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Range slider styling */
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -193,187 +305,51 @@ input[type="range"]::-moz-range-progress {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* container no longer controls layout; keep for semantics */
|
/* ========================================
|
||||||
.container {
|
MOBILE STYLES
|
||||||
position: relative;
|
======================================== */
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas already full screen; nothing to change there */
|
|
||||||
|
|
||||||
/* --- Toolbar square when collapsed --- */
|
|
||||||
.toolbar {
|
|
||||||
position: fixed;
|
|
||||||
top: 12px;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(15,15,15,.65);
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed {
|
|
||||||
left: 12px;
|
|
||||||
right: auto; /* stop spanning full width */
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
gap: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed .group,
|
|
||||||
.toolbar.collapsed #restart,
|
|
||||||
.toolbar.collapsed #permalink,
|
|
||||||
.toolbar.collapsed .spacer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed #toggleToolbar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0; /* hide the word "Toolbar" */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed #toggleToolbar::before {
|
|
||||||
content: "☰"; /* icon only */
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Rules panel as a floating card; tiny square when closed --- */
|
|
||||||
.rulepanel {
|
|
||||||
position: fixed;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(11,11,11,.65);
|
|
||||||
border: 1px solid #1a1a1a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
max-height: 42vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] {
|
|
||||||
max-height: calc(100dvh - 24px - 60px);
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-gutter: stable both-edges; /* avoids layout jump */
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] {
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
/* Firefox */
|
|
||||||
.rulepanel[open]::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
/* WebKit */
|
|
||||||
|
|
||||||
.rulepanel > summary {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
padding: .6rem .75rem;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 1px solid #1a1a1a;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel > summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] > summary::after {
|
|
||||||
content: "▾";
|
|
||||||
margin-left: auto;
|
|
||||||
opacity: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* closed => tiny square pinned bottom-right */
|
|
||||||
.rulepanel:not([open]) {
|
|
||||||
left: 12px;
|
|
||||||
right: auto; /* stop spanning full width */
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
gap: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 0; /* hide label text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary::after {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
/* no caret */
|
|
||||||
.rulepanel:not([open]) > summary::before {
|
|
||||||
content: "Rules"; /* icon only */
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep sliders/layout rules you already have */
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
/* Default to FAB */
|
/* Toolbar defaults to FAB on mobile */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
top: 12px;
|
top: var(--panel-gap);
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
width: 44px;
|
width: var(--fab-size);
|
||||||
height: 44px;
|
height: var(--fab-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer {
|
.toolbar .group,
|
||||||
|
.toolbar #restart,
|
||||||
|
.toolbar #permalink,
|
||||||
|
.toolbar .spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FAB button icon */
|
|
||||||
#toggleToolbar {
|
#toggleToolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: var(--fab-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-fab);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleToolbar::before {
|
#toggleToolbar::before {
|
||||||
width: 44px;
|
width: var(--fab-size);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded drawer */
|
/* Expanded drawer state on mobile */
|
||||||
.toolbar.mobile.open {
|
.toolbar.mobile.open {
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
width: min(85vw, 420px);
|
width: min(85vw, 420px);
|
||||||
height: 70vh; /* scroll within drawer if needed */
|
height: 70vh;
|
||||||
padding: .6rem .75rem;
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .65rem;
|
gap: 0.65rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +361,7 @@ input[type="range"]::-moz-range-progress {
|
|||||||
|
|
||||||
.toolbar.mobile.open .group {
|
.toolbar.mobile.open .group {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.mobile.open input[type="number"],
|
.toolbar.mobile.open input[type="number"],
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #0a0a0a;
|
|
||||||
--color-surface: rgba(255, 255, 255, 0.02);
|
|
||||||
--color-surface-hover: rgba(255, 255, 255, 0.04);
|
|
||||||
--color-surface-raised: rgba(255, 255, 255, 0.05);
|
|
||||||
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
|
|
||||||
|
|
||||||
/* Text */
|
|
||||||
--color-heading: #fff;
|
|
||||||
--color-heading-secondary: #e0e0e0;
|
|
||||||
--color-text: #b0b0b0;
|
|
||||||
--color-text-muted: #aaa;
|
|
||||||
--color-text-subtle: #777;
|
|
||||||
|
|
||||||
/* Accent */
|
|
||||||
--color-accent: #09ff00;
|
|
||||||
--color-accent-hover: #5fff5f;
|
|
||||||
|
|
||||||
/* Borders */
|
|
||||||
--color-border: rgba(255, 255, 255, 0.07);
|
|
||||||
--color-border-hover: rgba(255, 255, 255, 0.15);
|
|
||||||
--color-accent-border: rgba(9, 255, 0, 0.4);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCPUGame(difficulty, color = "random") {
|
async createCPUGame(difficulty) {
|
||||||
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -14,20 +14,6 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPgn(gameId) {
|
|
||||||
const response = await fetch(`/api/chess/${gameId}/pgn`);
|
|
||||||
if (!response.ok) throw new Error("PGN unavailable");
|
|
||||||
return await response.text();
|
|
||||||
},
|
|
||||||
|
|
||||||
async forfeit(gameId, playerId) {
|
|
||||||
await fetch("/api/chess/forfeit", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLegalMoves(pieceId) {
|
async getLegalMoves(pieceId) {
|
||||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||||
if (!response.ok) throw new Error("API failed");
|
if (!response.ok) throw new Error("API failed");
|
||||||
@@ -46,15 +32,13 @@ const ChessAPI = {
|
|||||||
throw new Error(message || "Invalid move or not your turn.");
|
throw new Error(message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The move endpoint returns both the move result and the full resulting
|
const result = await response.json();
|
||||||
// board state, so the caller can render without a follow-up fetch.
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.result.success) {
|
if (!result.success) {
|
||||||
throw new Error(data.result.message || "Invalid move or not your turn.");
|
throw new Error(result.message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMove(targetRow, targetCol) {
|
async handleMove(targetRow, targetCol) {
|
||||||
@@ -87,14 +71,16 @@ const ChessAPI = {
|
|||||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { result, state } = await this.makeMove(moveDto);
|
const moveResult = await this.makeMove(moveDto);
|
||||||
|
|
||||||
ChessBoard.renderState(state);
|
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||||
|
ChessBoard.renderPieces(updatedGame.pieces);
|
||||||
|
|
||||||
GameState.clearSelection();
|
GameState.clearSelection();
|
||||||
ChessInteractions.clearHighlights();
|
ChessInteractions.clearHighlights();
|
||||||
|
|
||||||
this.alertGameStatusChange(result);
|
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||||
|
this.alertGameStatusChange(moveResult);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("❌ " + error.message);
|
alert("❌ " + error.message);
|
||||||
@@ -115,13 +101,9 @@ const ChessAPI = {
|
|||||||
} else if (moveResult.isStalemate) {
|
} else if (moveResult.isStalemate) {
|
||||||
alert("🤝 Stalemate!");
|
alert("🤝 Stalemate!");
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
} else if (moveResult.isThreefoldRepetition) {
|
|
||||||
alert("🤝 Draw by threefold repetition!");
|
|
||||||
gameOver = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
showCopyPgn(GameState.currentGameId);
|
|
||||||
await ChessSignalR.leaveGame();
|
await ChessSignalR.leaveGame();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
@@ -1,91 +1,4 @@
|
|||||||
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
|
||||||
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
|
||||||
|
|
||||||
const ChessBoard = {
|
const ChessBoard = {
|
||||||
// Render from a full game-state payload (the shape returned by the move/state
|
|
||||||
// endpoints and pushed over SignalR). Ignores state older than what's already
|
|
||||||
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
|
||||||
renderState(state) {
|
|
||||||
if (!state || !GameState.shouldApply(state.version)) return;
|
|
||||||
this.renderPieces(state.pieces);
|
|
||||||
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
|
||||||
this.renderMoveList(state.sanHistory);
|
|
||||||
this.renderStatus(state);
|
|
||||||
GameState.setVersion(state.version);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Two-column numbered move list (white move, black move), latest highlighted.
|
|
||||||
renderMoveList(sanHistory) {
|
|
||||||
const list = document.getElementById("moveList");
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
const san = sanHistory ?? [];
|
|
||||||
|
|
||||||
if (san.length === 0) {
|
|
||||||
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = "";
|
|
||||||
|
|
||||||
for (let i = 0; i < san.length; i += 2) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "moveRow";
|
|
||||||
|
|
||||||
const num = document.createElement("span");
|
|
||||||
num.className = "moveNum";
|
|
||||||
num.textContent = `${i / 2 + 1}.`;
|
|
||||||
row.appendChild(num);
|
|
||||||
|
|
||||||
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
|
||||||
|
|
||||||
if (i + 1 < san.length)
|
|
||||||
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
|
||||||
|
|
||||||
list.appendChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
list.scrollTop = list.scrollHeight;
|
|
||||||
},
|
|
||||||
|
|
||||||
moveCell(san, isLatest) {
|
|
||||||
const cell = document.createElement("span");
|
|
||||||
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
|
||||||
cell.textContent = san;
|
|
||||||
return cell;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderStatus(state) {
|
|
||||||
let text;
|
|
||||||
let alert = false;
|
|
||||||
|
|
||||||
if (state.isCheckmate) {
|
|
||||||
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
|
||||||
text = `Checkmate — ${winner} wins`;
|
|
||||||
alert = true;
|
|
||||||
} else if (state.isStalemate) {
|
|
||||||
text = "Draw — stalemate";
|
|
||||||
alert = true;
|
|
||||||
} else if (state.isThreefoldRepetition) {
|
|
||||||
text = "Draw — threefold repetition";
|
|
||||||
alert = true;
|
|
||||||
} else {
|
|
||||||
text = `${state.currentPlayer} to move`;
|
|
||||||
if (state.isCheck) {
|
|
||||||
text += " — check";
|
|
||||||
alert = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror to both the desktop panel status and the mobile bar status.
|
|
||||||
["statusLine", "mobileStatus"].forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = text;
|
|
||||||
el.classList.toggle("alert", alert);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
renderPieces(pieces) {
|
renderPieces(pieces) {
|
||||||
this.clearAllSquares();
|
this.clearAllSquares();
|
||||||
this.renderCoordinateLabels();
|
this.renderCoordinateLabels();
|
||||||
@@ -94,48 +7,6 @@ const ChessBoard = {
|
|||||||
this.highlightPreviousMove();
|
this.highlightPreviousMove();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Show each side's captured pieces and the leading side's material advantage,
|
|
||||||
// arranged so the current player's tray sits below the board.
|
|
||||||
renderCapturedTrays(activePieces, capturedPieces) {
|
|
||||||
const captured = capturedPieces ?? [];
|
|
||||||
|
|
||||||
// A captured piece's color is the side that lost it, so White's haul is the
|
|
||||||
// captured Black pieces, and vice versa.
|
|
||||||
const whiteCaptured = captured.filter(p => p.color === 1);
|
|
||||||
const blackCaptured = captured.filter(p => p.color === 0);
|
|
||||||
|
|
||||||
// Net material from pieces still on the board, so promotions count correctly.
|
|
||||||
const advantage = (activePieces ?? []).reduce(
|
|
||||||
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
|
||||||
|
|
||||||
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
|
||||||
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
|
||||||
|
|
||||||
const isWhite = GameState.currentPlayerIsWhite !== false;
|
|
||||||
this.fillCapturedTray("bottom", isWhite ? white : black);
|
|
||||||
this.fillCapturedTray("top", isWhite ? black : white);
|
|
||||||
},
|
|
||||||
|
|
||||||
fillCapturedTray(position, side) {
|
|
||||||
const tray = document.getElementById(`captured-${position}`);
|
|
||||||
const badge = document.getElementById(`advantage-${position}`);
|
|
||||||
if (!tray || !badge) return;
|
|
||||||
|
|
||||||
tray.innerHTML = "";
|
|
||||||
[...side.captured]
|
|
||||||
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
|
||||||
.forEach(piece => {
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = ChessUtils.getPieceImageUrl(piece);
|
|
||||||
img.alt = piece.type;
|
|
||||||
img.className = "capturedPiece";
|
|
||||||
img.draggable = false;
|
|
||||||
tray.appendChild(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
|
||||||
},
|
|
||||||
|
|
||||||
clearAllSquares() {
|
clearAllSquares() {
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const square = document.getElementById(`square-${i}`);
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|||||||
@@ -23,16 +23,6 @@ const ChessModals = {
|
|||||||
resolve(difficulty);
|
resolve(difficulty);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
promptColor() {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
document.getElementById("colorModal").style.display = "block";
|
|
||||||
window.selectColor = (color) => {
|
|
||||||
document.getElementById("colorModal").style.display = "none";
|
|
||||||
resolve(color);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ const ChessSignalR = {
|
|||||||
console.error("❌ SignalR connection closed:", err?.message);
|
console.error("❌ SignalR connection closed:", err?.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||||
});
|
|
||||||
|
|
||||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
|
||||||
await this.handleGameOver(gameId, winner, reason);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -28,28 +24,20 @@ const ChessSignalR = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||||
if (gameId !== GameState.currentGameId) return;
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
// Drop the echo of our own move and any out-of-order delivery.
|
const gameState = await ChessAPI.getGameState(gameId);
|
||||||
if (!GameState.shouldApply(state?.version)) return;
|
|
||||||
|
|
||||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||||
ChessBoard.renderState(state);
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleGameOver(gameId, winner, reason) {
|
async notifyMoveMade(moveDto, moveResult) {
|
||||||
if (gameId !== GameState.currentGameId) return;
|
if (this.connection) {
|
||||||
|
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||||
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
|
}
|
||||||
|
|
||||||
if (reason === "forfeit")
|
|
||||||
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
|
||||||
|
|
||||||
showCopyPgn(gameId);
|
|
||||||
await this.leaveGame();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async leaveGame() {
|
async leaveGame() {
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ const GameState = {
|
|||||||
legalMoves: [],
|
legalMoves: [],
|
||||||
previousMoveStart: null,
|
previousMoveStart: null,
|
||||||
previousMoveEnd: null,
|
previousMoveEnd: null,
|
||||||
// Highest ply (move count) already rendered. Lets us ignore stale or
|
|
||||||
// already-applied updates that arrive out of order, including the echo
|
|
||||||
// of our own move.
|
|
||||||
lastVersion: -1,
|
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.currentGameId = null;
|
this.currentGameId = null;
|
||||||
@@ -19,22 +15,12 @@ const GameState = {
|
|||||||
this.legalMoves = [];
|
this.legalMoves = [];
|
||||||
this.previousMoveStart = null;
|
this.previousMoveStart = null;
|
||||||
this.previousMoveEnd = null;
|
this.previousMoveEnd = null;
|
||||||
this.lastVersion = -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
shouldApply(version) {
|
|
||||||
return typeof version !== "number" || version > this.lastVersion;
|
|
||||||
},
|
|
||||||
|
|
||||||
setVersion(version) {
|
|
||||||
if (typeof version === "number") this.lastVersion = version;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setGameInfo(gameId, playerId, isWhite) {
|
setGameInfo(gameId, playerId, isWhite) {
|
||||||
this.currentGameId = gameId;
|
this.currentGameId = gameId;
|
||||||
this.currentPlayerId = playerId;
|
this.currentPlayerId = playerId;
|
||||||
this.currentPlayerIsWhite = isWhite;
|
this.currentPlayerIsWhite = isWhite;
|
||||||
this.lastVersion = -1;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedPiece(piece) {
|
setSelectedPiece(piece) {
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
const Spectate = {
|
|
||||||
connection: null,
|
|
||||||
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
|
||||||
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
|
||||||
|
|
||||||
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.connection = new signalR.HubConnectionBuilder()
|
|
||||||
.withUrl("/chessHub")
|
|
||||||
.configureLogging(signalR.LogLevel.Warning)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
|
||||||
this.handleMoveUpdate(gameId, moveDto, state));
|
|
||||||
|
|
||||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.start();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ SignalR failed to start:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refreshGames();
|
|
||||||
await this.loadAutoTrainCount();
|
|
||||||
setInterval(() => this.refreshGames(), 5000);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Auto-training runs server-side; show its target count and let it be changed here.
|
|
||||||
async loadAutoTrainCount() {
|
|
||||||
const input = document.getElementById("autoTrainCount");
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/chess/autotrain");
|
|
||||||
const data = await response.json();
|
|
||||||
input.max = data.max;
|
|
||||||
// Don't clobber the value while the user is editing it.
|
|
||||||
if (document.activeElement !== input)
|
|
||||||
input.value = data.count;
|
|
||||||
} catch {
|
|
||||||
// Leave the control as-is if auto-training status can't be read.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async setAutoTrainCount() {
|
|
||||||
const input = document.getElementById("autoTrainCount");
|
|
||||||
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
|
||||||
const data = await response.json();
|
|
||||||
input.value = data.count;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Could not set the auto-training game count.", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async startCpuGame() {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
whiteEngine: document.getElementById("whiteEngine").value,
|
|
||||||
whiteSkill: document.getElementById("whiteSkill").value,
|
|
||||||
blackEngine: document.getElementById("blackEngine").value,
|
|
||||||
blackSkill: document.getElementById("blackSkill").value
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`/api/chess/watch/cpu?${params}`);
|
|
||||||
await this.refreshGames();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshGames() {
|
|
||||||
let games;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/chess/active");
|
|
||||||
games = await response.json();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIds = new Set(games.map(g => g.gameId));
|
|
||||||
|
|
||||||
for (const gameId of [...this.games.keys()])
|
|
||||||
if (!activeIds.has(gameId))
|
|
||||||
await this.removeGame(gameId);
|
|
||||||
|
|
||||||
for (const game of games)
|
|
||||||
if (this.games.has(game.gameId))
|
|
||||||
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
|
||||||
else
|
|
||||||
await this.addGame(game);
|
|
||||||
|
|
||||||
const status = document.getElementById("watchStatus");
|
|
||||||
status.textContent = games.length === 0
|
|
||||||
? "No games are being played right now."
|
|
||||||
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
|
||||||
},
|
|
||||||
|
|
||||||
async addGame(game) {
|
|
||||||
this.games.set(game.gameId, {
|
|
||||||
isVsComputer: game.isVsComputer,
|
|
||||||
isComputerVsComputer: game.isComputerVsComputer,
|
|
||||||
whiteEngine: game.whiteEngine,
|
|
||||||
blackEngine: game.blackEngine
|
|
||||||
});
|
|
||||||
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "gameCard";
|
|
||||||
card.id = `card-${game.gameId}`;
|
|
||||||
card.onclick = () => this.enterFullscreen(game.gameId);
|
|
||||||
|
|
||||||
const back = document.createElement("button");
|
|
||||||
back.className = "backButton";
|
|
||||||
back.textContent = "← Back";
|
|
||||||
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
|
||||||
card.appendChild(back);
|
|
||||||
|
|
||||||
const header = document.createElement("div");
|
|
||||||
header.className = "gameCardHeader";
|
|
||||||
header.id = `header-${game.gameId}`;
|
|
||||||
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
|
||||||
card.appendChild(header);
|
|
||||||
|
|
||||||
const board = document.createElement("div");
|
|
||||||
board.className = "miniBoard";
|
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const square = document.createElement("div");
|
|
||||||
square.id = `sq-${game.gameId}-${i}`;
|
|
||||||
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
|
||||||
board.appendChild(square);
|
|
||||||
}
|
|
||||||
|
|
||||||
card.appendChild(board);
|
|
||||||
document.getElementById("gamesFeed").appendChild(card);
|
|
||||||
|
|
||||||
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
|
||||||
await this.renderGame(game.gameId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeGame(gameId) {
|
|
||||||
this.games.delete(gameId);
|
|
||||||
this.pgns.delete(gameId);
|
|
||||||
document.getElementById(`card-${gameId}`)?.remove();
|
|
||||||
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
|
||||||
},
|
|
||||||
|
|
||||||
async renderGame(gameId) {
|
|
||||||
const response = await fetch(`/api/chess/${gameId}`);
|
|
||||||
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
this.renderFromState(gameId, await response.json());
|
|
||||||
},
|
|
||||||
|
|
||||||
renderFromState(gameId, state) {
|
|
||||||
const result = this.resultTextFromState(state);
|
|
||||||
const stored = this.games.get(gameId);
|
|
||||||
|
|
||||||
if (stored) stored.result = result;
|
|
||||||
|
|
||||||
this.renderPieces(gameId, state.pieces);
|
|
||||||
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
|
||||||
this.setResult(gameId, result);
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleMoveUpdate(gameId, moveDto, state) {
|
|
||||||
if (!this.games.has(gameId)) return;
|
|
||||||
|
|
||||||
// Render from the pushed state; fall back to a fetch only if it's missing.
|
|
||||||
if (state) this.renderFromState(gameId, state);
|
|
||||||
else await this.renderGame(gameId);
|
|
||||||
|
|
||||||
this.highlightMove(gameId, moveDto);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderPieces(gameId, pieces) {
|
|
||||||
this.clearBoard(gameId);
|
|
||||||
|
|
||||||
pieces.forEach(piece => {
|
|
||||||
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
|
||||||
|
|
||||||
if (!square) return;
|
|
||||||
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = this.pieceImageUrl(piece);
|
|
||||||
img.alt = piece.type;
|
|
||||||
img.className = "chessPiece";
|
|
||||||
img.draggable = false;
|
|
||||||
square.appendChild(img);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearBoard(gameId) {
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const square = document.getElementById(`sq-${gameId}-${i}`);
|
|
||||||
|
|
||||||
if (!square) continue;
|
|
||||||
|
|
||||||
square.innerHTML = "";
|
|
||||||
square.classList.remove("previous-start", "previous-end");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
highlightMove(gameId, moveDto) {
|
|
||||||
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
|
||||||
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
|
||||||
},
|
|
||||||
|
|
||||||
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
|
||||||
const stored = this.games.get(gameId);
|
|
||||||
const header = document.getElementById(`header-${gameId}`);
|
|
||||||
|
|
||||||
if (stored && header)
|
|
||||||
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
|
||||||
},
|
|
||||||
|
|
||||||
gameLabel(stored) {
|
|
||||||
if (stored.isComputerVsComputer)
|
|
||||||
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
|
||||||
if (stored.isVsComputer) return "Vs CPU";
|
|
||||||
return "Player vs Player";
|
|
||||||
},
|
|
||||||
|
|
||||||
engineName(kind) {
|
|
||||||
switch (kind) {
|
|
||||||
case "CustomLearned": return "Learned";
|
|
||||||
case "Custom": return "Custom";
|
|
||||||
case "Stockfish": return "Stockfish";
|
|
||||||
default: return kind || "CPU";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
|
||||||
if (stored.result)
|
|
||||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
|
||||||
|
|
||||||
const check = isCheck ? " • check" : "";
|
|
||||||
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
resultTextFromState(state) {
|
|
||||||
if (state.isCheckmate)
|
|
||||||
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
|
||||||
if (state.isStalemate)
|
|
||||||
return "Draw — stalemate";
|
|
||||||
if (state.isThreefoldRepetition)
|
|
||||||
return "Draw — threefold repetition";
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
setResult(gameId, text) {
|
|
||||||
const card = document.getElementById(`card-${gameId}`);
|
|
||||||
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
let banner = card.querySelector(".gameResult");
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
banner?.remove();
|
|
||||||
card.querySelector(".copyPgnBtn")?.remove();
|
|
||||||
card.classList.remove("over");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!banner) {
|
|
||||||
banner = document.createElement("div");
|
|
||||||
banner.className = "gameResult";
|
|
||||||
card.appendChild(banner);
|
|
||||||
}
|
|
||||||
|
|
||||||
banner.textContent = text;
|
|
||||||
card.classList.add("over");
|
|
||||||
this.addCopyPgn(gameId, card);
|
|
||||||
},
|
|
||||||
|
|
||||||
addCopyPgn(gameId, card) {
|
|
||||||
if (card.querySelector(".copyPgnBtn")) return;
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.className = "copyPgnBtn";
|
|
||||||
btn.textContent = "Copy PGN";
|
|
||||||
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
|
||||||
card.appendChild(btn);
|
|
||||||
|
|
||||||
// Prefetch now (while the game is still in memory) so copy works during the
|
|
||||||
// brief window before the finished game is cleaned up.
|
|
||||||
fetch(`/api/chess/${gameId}/pgn`)
|
|
||||||
.then(r => r.ok ? r.text() : null)
|
|
||||||
.then(t => { if (t) this.pgns.set(gameId, t); })
|
|
||||||
.catch(() => { });
|
|
||||||
},
|
|
||||||
|
|
||||||
async copyPgn(gameId) {
|
|
||||||
let pgn = this.pgns.get(gameId);
|
|
||||||
|
|
||||||
if (!pgn) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
|
||||||
if (r.ok) pgn = await r.text();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pgn) {
|
|
||||||
alert("PGN is no longer available for this game.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(pgn);
|
|
||||||
alert("📋 PGN copied to clipboard!");
|
|
||||||
} catch {
|
|
||||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
enterFullscreen(gameId) {
|
|
||||||
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
|
||||||
document.body.classList.add("fullscreen-open");
|
|
||||||
},
|
|
||||||
|
|
||||||
exitFullscreen(gameId, event) {
|
|
||||||
event?.stopPropagation();
|
|
||||||
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
|
||||||
document.body.classList.remove("fullscreen-open");
|
|
||||||
},
|
|
||||||
|
|
||||||
pieceImageUrl(piece) {
|
|
||||||
const color = piece.color === 0 ? "White" : "Black";
|
|
||||||
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("load", () => Spectate.init());
|
|
||||||
|
|
||||||
console.log("Spectate.js loaded");
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
const Weights = {
|
|
||||||
async init() {
|
|
||||||
await this.refresh();
|
|
||||||
},
|
|
||||||
|
|
||||||
async refresh() {
|
|
||||||
let data;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/chess/weights");
|
|
||||||
data = await response.json();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Could not load weights.", err);
|
|
||||||
document.getElementById("weightsStatus").textContent = "Could not load weights.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.renderBoardSet(data.mg, "weightsGridMg");
|
|
||||||
this.renderBoardSet(data.eg, "weightsGridEg");
|
|
||||||
this.renderFeatures(data.features);
|
|
||||||
|
|
||||||
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|
|
||||||
|| data.features.some(f => f.value !== 0);
|
|
||||||
|
|
||||||
document.getElementById("weightsStatus").textContent = trained
|
|
||||||
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
|
|
||||||
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
|
|
||||||
},
|
|
||||||
|
|
||||||
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
|
|
||||||
renderBoardSet(pieces, containerId) {
|
|
||||||
const overall = new Array(64).fill(0);
|
|
||||||
for (const piece of pieces)
|
|
||||||
for (let sq = 0; sq < 64; sq++)
|
|
||||||
overall[sq] += piece.squares[sq];
|
|
||||||
|
|
||||||
const boards = [{ name: "Overall", squares: overall }, ...pieces];
|
|
||||||
const grid = document.getElementById(containerId);
|
|
||||||
grid.innerHTML = "";
|
|
||||||
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
|
|
||||||
},
|
|
||||||
|
|
||||||
buildBoard(board) {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "weightBoard";
|
|
||||||
|
|
||||||
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
|
||||||
|
|
||||||
const header = document.createElement("div");
|
|
||||||
header.className = "weightBoardHeader";
|
|
||||||
if (board.name !== "Overall") {
|
|
||||||
const icon = document.createElement("img");
|
|
||||||
icon.src = `/images/Chess Images/White${board.name}.svg`;
|
|
||||||
icon.alt = board.name;
|
|
||||||
icon.className = "weightBoardIcon";
|
|
||||||
header.appendChild(icon);
|
|
||||||
}
|
|
||||||
const title = document.createElement("span");
|
|
||||||
title.textContent = board.name;
|
|
||||||
header.appendChild(title);
|
|
||||||
const range = document.createElement("span");
|
|
||||||
range.className = "weightRange";
|
|
||||||
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
|
|
||||||
header.appendChild(range);
|
|
||||||
wrapper.appendChild(header);
|
|
||||||
|
|
||||||
const heat = document.createElement("div");
|
|
||||||
heat.className = "miniHeat";
|
|
||||||
|
|
||||||
for (let row = 0; row < 8; row++) {
|
|
||||||
const rankLabel = document.createElement("div");
|
|
||||||
rankLabel.className = "heatLabel";
|
|
||||||
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
|
|
||||||
heat.appendChild(rankLabel);
|
|
||||||
|
|
||||||
for (let col = 0; col < 8; col++) {
|
|
||||||
const rank = 7 - row; // rank index, 0 = rank 1
|
|
||||||
const sq = rank * 8 + col; // white-relative square (A1 = 0)
|
|
||||||
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
heat.appendChild(this.cornerSpacer());
|
|
||||||
for (let col = 0; col < 8; col++) {
|
|
||||||
const fileLabel = document.createElement("div");
|
|
||||||
fileLabel.className = "heatLabel";
|
|
||||||
fileLabel.textContent = String.fromCharCode(97 + col);
|
|
||||||
heat.appendChild(fileLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.appendChild(heat);
|
|
||||||
return wrapper;
|
|
||||||
},
|
|
||||||
|
|
||||||
buildSquare(value, sq, maxAbs) {
|
|
||||||
const cell = document.createElement("div");
|
|
||||||
cell.className = "heatSquare";
|
|
||||||
|
|
||||||
if (value !== 0 && maxAbs > 0) {
|
|
||||||
const ratio = Math.abs(value) / maxAbs;
|
|
||||||
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
|
|
||||||
cell.style.backgroundColor = value > 0
|
|
||||||
? `rgba(74, 134, 232, ${alpha})` // high -> blue
|
|
||||||
: `rgba(232, 74, 74, ${alpha})`; // low -> red
|
|
||||||
cell.textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = String.fromCharCode(97 + (sq & 7));
|
|
||||||
const rank = (sq >> 3) + 1;
|
|
||||||
cell.title = `${file}${rank}: ${value}`;
|
|
||||||
return cell;
|
|
||||||
},
|
|
||||||
|
|
||||||
cornerSpacer() {
|
|
||||||
const spacer = document.createElement("div");
|
|
||||||
spacer.className = "heatLabel";
|
|
||||||
return spacer;
|
|
||||||
},
|
|
||||||
|
|
||||||
// features: [{ name, value }]
|
|
||||||
renderFeatures(features) {
|
|
||||||
const panel = document.getElementById("featureWeights");
|
|
||||||
panel.innerHTML = "";
|
|
||||||
|
|
||||||
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
|
|
||||||
|
|
||||||
features.forEach(feature => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "featureRow";
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "featureLabel";
|
|
||||||
label.textContent = feature.name;
|
|
||||||
|
|
||||||
const track = document.createElement("div");
|
|
||||||
track.className = "featureTrack";
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = "featureBar";
|
|
||||||
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
|
|
||||||
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
|
||||||
bar.style.backgroundColor = feature.value >= 0
|
|
||||||
? "rgba(74, 134, 232, 0.85)"
|
|
||||||
: "rgba(232, 74, 74, 0.85)";
|
|
||||||
track.appendChild(bar);
|
|
||||||
|
|
||||||
const value = document.createElement("span");
|
|
||||||
value.className = "featureValue";
|
|
||||||
value.textContent = feature.value;
|
|
||||||
|
|
||||||
row.append(label, track, value);
|
|
||||||
panel.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("load", () => Weights.init());
|
|
||||||
|
|
||||||
console.log("Weights.js loaded");
|
|
||||||
@@ -1,65 +1,5 @@
|
|||||||
let lastPgn = null;
|
|
||||||
|
|
||||||
async function showCopyPgn(gameId) {
|
|
||||||
try {
|
|
||||||
lastPgn = await ChessAPI.getPgn(gameId);
|
|
||||||
const btn = document.getElementById("copyPgnBtn");
|
|
||||||
if (btn) btn.style.display = "";
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not load PGN.", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPgn() {
|
|
||||||
if (!lastPgn) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(lastPgn);
|
|
||||||
alert("📋 PGN copied to clipboard!");
|
|
||||||
} catch {
|
|
||||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCopyPgn() {
|
|
||||||
lastPgn = null;
|
|
||||||
const btn = document.getElementById("copyPgnBtn");
|
|
||||||
if (btn) btn.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.copyPgn = copyPgn;
|
|
||||||
window.showCopyPgn = showCopyPgn;
|
|
||||||
|
|
||||||
// Mobile slide-up menu (move list + secondary actions).
|
|
||||||
function toggleMenu() {
|
|
||||||
document.body.classList.toggle("menu-open");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMenu() {
|
|
||||||
document.body.classList.remove("menu-open");
|
|
||||||
}
|
|
||||||
|
|
||||||
window.toggleMenu = toggleMenu;
|
|
||||||
window.closeMenu = closeMenu;
|
|
||||||
|
|
||||||
async function forfeitCurrentGame() {
|
|
||||||
const gameId = ChessUtils.getCookie("chessGameId");
|
|
||||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
|
||||||
|
|
||||||
if (!gameId || !playerId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ChessAPI.forfeit(gameId, playerId);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not forfeit previous game.", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startNewGame() {
|
async function startNewGame() {
|
||||||
closeMenu();
|
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
|
||||||
resetCopyPgn();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gameData = await ChessAPI.joinGame();
|
const gameData = await ChessAPI.joinGame();
|
||||||
@@ -76,7 +16,7 @@ async function startNewGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderState(gameState);
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start new game:", error);
|
console.error("Failed to start new game:", error);
|
||||||
@@ -84,15 +24,11 @@ async function startNewGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startCPUGame() {
|
async function startCPUGame() {
|
||||||
closeMenu();
|
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
|
||||||
resetCopyPgn();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const difficulty = await ChessModals.promptDifficulty();
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
const color = await ChessModals.promptColor();
|
const gameData = await ChessAPI.createCPUGame(difficulty);
|
||||||
const gameData = await ChessAPI.createCPUGame(difficulty, color);
|
|
||||||
|
|
||||||
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
GameState.clearPreviousMove();
|
GameState.clearPreviousMove();
|
||||||
@@ -106,7 +42,7 @@ async function startCPUGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderState(gameState);
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start CPU game:", error);
|
console.error("Failed to start CPU game:", error);
|
||||||
@@ -126,7 +62,7 @@ async function resumeSavedGame() {
|
|||||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||||
|
|
||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
ChessBoard.renderState(gameState);
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Saved game not found or expired.", err);
|
console.warn("Saved game not found or expired.", err);
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
* Microphone capture and chirp playback, both indexed by AudioContext frame.
|
|
||||||
*
|
|
||||||
* Frames, not wall-clock: currentTime * sampleRate is exactly the frame number, so a playback
|
|
||||||
* scheduled at an audio-clock time converts directly into a position in the recording. Nothing here
|
|
||||||
* depends on a timer firing on schedule, which matters because a hidden tab's timers are throttled
|
|
||||||
* to about once a second and its chirp would land in another device's slot.
|
|
||||||
*/
|
|
||||||
const EchoAudio = {
|
|
||||||
TARGET_SAMPLE_RATE: 48000,
|
|
||||||
RING_SECONDS: 20,
|
|
||||||
BLUETOOTH_HINTS: /bluetooth|airpod|hands-?free|headset|\bbt\b|wireless/i,
|
|
||||||
|
|
||||||
context: null,
|
|
||||||
stream: null,
|
|
||||||
capture: null,
|
|
||||||
ring: null,
|
|
||||||
highestFrame: 0,
|
|
||||||
peakAmplitude: 0,
|
|
||||||
warnings: [],
|
|
||||||
onSamples: null,
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
if (this.context) return this.describe();
|
|
||||||
|
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
// Echo cancellation exists to delete sounds this device just played, which is exactly
|
|
||||||
// the measurement. Gain control and noise suppression distort the chirp's envelope.
|
|
||||||
audio: {
|
|
||||||
echoCancellation: false,
|
|
||||||
noiseSuppression: false,
|
|
||||||
autoGainControl: false,
|
|
||||||
channelCount: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.context = new AudioContext({ sampleRate: this.TARGET_SAMPLE_RATE, latencyHint: "interactive" });
|
|
||||||
await this.context.audioWorklet.addModule("/js/EchoScripts/EchoCaptureProcessor.js");
|
|
||||||
await this.context.resume();
|
|
||||||
|
|
||||||
this.ring = new Float32Array(Math.ceil(this.context.sampleRate * this.RING_SECONDS));
|
|
||||||
this.attachCapture();
|
|
||||||
this.warnings = this.inspectDevice();
|
|
||||||
|
|
||||||
return this.describe();
|
|
||||||
},
|
|
||||||
|
|
||||||
attachCapture() {
|
|
||||||
const source = this.context.createMediaStreamSource(this.stream);
|
|
||||||
this.capture = new AudioWorkletNode(this.context, "echo-capture", { numberOfOutputs: 0 });
|
|
||||||
this.capture.port.onmessage = ({ data }) => this.write(data.frame, data.samples);
|
|
||||||
source.connect(this.capture);
|
|
||||||
return this.capture;
|
|
||||||
},
|
|
||||||
|
|
||||||
write(frame, samples) {
|
|
||||||
const capacity = this.ring.length;
|
|
||||||
let loudest = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
this.ring[(frame + i) % capacity] = samples[i];
|
|
||||||
loudest = Math.max(loudest, Math.abs(samples[i]));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.peakAmplitude = Math.max(this.peakAmplitude * 0.95, loudest);
|
|
||||||
this.highestFrame = Math.max(this.highestFrame, frame + samples.length);
|
|
||||||
this.onSamples?.(this.highestFrame);
|
|
||||||
return this.highestFrame;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** The recording position for an AudioContext time. Exact: currentTime * sampleRate is a frame. */
|
|
||||||
frameAt(contextTime) {
|
|
||||||
return Math.round(contextTime * this.context.sampleRate);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Copy an absolute frame range out of the ring, or null if it is not (or no longer) held. */
|
|
||||||
read(startFrame, length) {
|
|
||||||
if (length <= 0) return null;
|
|
||||||
if (startFrame + length > this.highestFrame) return null;
|
|
||||||
if (startFrame < this.highestFrame - this.ring.length) return null;
|
|
||||||
|
|
||||||
const capacity = this.ring.length;
|
|
||||||
const window = new Float32Array(length);
|
|
||||||
for (let i = 0; i < length; i++) window[i] = this.ring[((startFrame + i) % capacity + capacity) % capacity];
|
|
||||||
|
|
||||||
return window;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule a chirp on the audio clock and return the frame it was scheduled for. When it
|
|
||||||
* actually leaves the speaker is later by an unknown output latency and does not need to be
|
|
||||||
* known — it is recovered from the recording of it.
|
|
||||||
*/
|
|
||||||
play(chirp, atContextTime, gain = 0.5) {
|
|
||||||
const when = Math.max(atContextTime, this.context.currentTime);
|
|
||||||
const buffer = this.context.createBuffer(1, chirp.length, this.context.sampleRate);
|
|
||||||
buffer.copyToChannel(chirp, 0);
|
|
||||||
|
|
||||||
const source = this.context.createBufferSource();
|
|
||||||
const volume = this.context.createGain();
|
|
||||||
volume.gain.value = gain;
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.connect(volume).connect(this.context.destination);
|
|
||||||
source.start(when);
|
|
||||||
|
|
||||||
return this.frameAt(when);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** How far behind the audio clock the main thread's view of the recording is running. */
|
|
||||||
captureLagSeconds() {
|
|
||||||
return this.context.currentTime - this.highestFrame / this.context.sampleRate;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Reported output latency, which bounds how late a scheduled chirp can reach the microphone. */
|
|
||||||
outputLatencySeconds() {
|
|
||||||
return (this.context.outputLatency || 0) + (this.context.baseLatency || 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
inspectDevice() {
|
|
||||||
const warnings = [];
|
|
||||||
const track = this.stream.getAudioTracks()[0];
|
|
||||||
const settings = track?.getSettings() ?? {};
|
|
||||||
|
|
||||||
if (this.context.sampleRate !== this.TARGET_SAMPLE_RATE)
|
|
||||||
warnings.push(`Running at ${this.context.sampleRate}Hz instead of 48000Hz — ranges stay correct but resolution drops.`);
|
|
||||||
|
|
||||||
if (this.BLUETOOTH_HINTS.test(track?.label ?? ""))
|
|
||||||
warnings.push("This looks like a Bluetooth microphone. Bluetooth resamples and re-times the stream, which breaks the measurement — switch to the built-in speaker and microphone.");
|
|
||||||
|
|
||||||
for (const [name, label] of [["echoCancellation", "Echo cancellation"], ["autoGainControl", "Auto gain"], ["noiseSuppression", "Noise suppression"]])
|
|
||||||
if (settings[name] === true) warnings.push(`${label} could not be turned off on this device — the measurement will be unreliable.`);
|
|
||||||
|
|
||||||
return warnings;
|
|
||||||
},
|
|
||||||
|
|
||||||
describe() {
|
|
||||||
const track = this.stream.getAudioTracks()[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
sampleRate: this.context.sampleRate,
|
|
||||||
label: track?.label ?? "microphone",
|
|
||||||
outputLatencyMs: Math.round(this.outputLatencySeconds() * 1000),
|
|
||||||
warnings: this.warnings
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
this.stream?.getTracks().forEach(track => track.stop());
|
|
||||||
await this.context?.close();
|
|
||||||
this.context = null;
|
|
||||||
this.stream = null;
|
|
||||||
this.capture = null;
|
|
||||||
this.ring = null;
|
|
||||||
this.highestFrame = 0;
|
|
||||||
this.onSamples = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") window.EchoAudio = EchoAudio;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* Capture worklet. Every block is tagged with the AudioContext frame it started at, which makes the
|
|
||||||
* context's own clock the index for all captured audio: a scheduled playback time converts to a
|
|
||||||
* recording position exactly, with no dependence on when the main thread got round to noticing.
|
|
||||||
*
|
|
||||||
* A worklet rather than ScriptProcessorNode because a dropped buffer would break the continuity
|
|
||||||
* that the sample count depends on.
|
|
||||||
*/
|
|
||||||
const BLOCK_SAMPLES = 2048;
|
|
||||||
|
|
||||||
class EchoCaptureProcessor extends AudioWorkletProcessor {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.block = new Float32Array(BLOCK_SAMPLES);
|
|
||||||
this.filled = 0;
|
|
||||||
this.blockStartFrame = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
process(inputs) {
|
|
||||||
const channel = inputs[0]?.[0];
|
|
||||||
if (!channel) return true;
|
|
||||||
|
|
||||||
for (let i = 0; i < channel.length; i++) {
|
|
||||||
if (this.filled === 0) this.blockStartFrame = currentFrame + i;
|
|
||||||
this.block[this.filled++] = channel[i];
|
|
||||||
if (this.filled === BLOCK_SAMPLES) this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
flush() {
|
|
||||||
const samples = this.block.slice(0, this.filled);
|
|
||||||
this.port.postMessage({ frame: this.blockStartFrame, samples }, [samples.buffer]);
|
|
||||||
this.filled = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProcessor("echo-capture", EchoCaptureProcessor);
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pure signal-processing and geometry for acoustic ranging. No DOM, no Web Audio, no network:
|
|
||||||
* everything here is a function of its arguments so the simulator and the tests can drive the
|
|
||||||
* exact code the page runs.
|
|
||||||
*/
|
|
||||||
const EchoDsp = {
|
|
||||||
speedOfSound(temperatureCelsius = 20) {
|
|
||||||
return 331.3 + 0.606 * temperatureCelsius;
|
|
||||||
},
|
|
||||||
|
|
||||||
nextPowerOfTwo(value) {
|
|
||||||
let size = 1;
|
|
||||||
while (size < value) size <<= 1;
|
|
||||||
return size;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Linear frequency sweep, tapered at both ends. A sweep is used rather than a tone because a
|
|
||||||
* tone's autocorrelation peaks once per period, leaving no unambiguous arrival to measure.
|
|
||||||
*/
|
|
||||||
makeChirp({ sampleRate, durationSeconds, startHz, endHz, taperFraction = 0.15 }) {
|
|
||||||
const length = Math.round(sampleRate * durationSeconds);
|
|
||||||
const sweepRate = (endHz - startHz) / durationSeconds;
|
|
||||||
const chirp = new Float32Array(length);
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const t = i / sampleRate;
|
|
||||||
chirp[i] = Math.sin(2 * Math.PI * (startHz * t + 0.5 * sweepRate * t * t));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.applyTaper(chirp, taperFraction);
|
|
||||||
},
|
|
||||||
|
|
||||||
applyTaper(signal, fraction) {
|
|
||||||
const edge = Math.max(1, Math.floor(signal.length * fraction));
|
|
||||||
|
|
||||||
for (let i = 0; i < edge; i++) {
|
|
||||||
const window = 0.5 - 0.5 * Math.cos((Math.PI * i) / edge);
|
|
||||||
signal[i] *= window;
|
|
||||||
signal[signal.length - 1 - i] *= window;
|
|
||||||
}
|
|
||||||
|
|
||||||
return signal;
|
|
||||||
},
|
|
||||||
|
|
||||||
twiddles(size) {
|
|
||||||
this._twiddleCache ??= new Map();
|
|
||||||
const cached = this._twiddleCache.get(size);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const half = size >> 1;
|
|
||||||
const table = { cos: new Float64Array(half), sin: new Float64Array(half) };
|
|
||||||
|
|
||||||
for (let i = 0; i < half; i++) {
|
|
||||||
const angle = (-2 * Math.PI * i) / size;
|
|
||||||
table.cos[i] = Math.cos(angle);
|
|
||||||
table.sin[i] = Math.sin(angle);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._twiddleCache.set(size, table);
|
|
||||||
return table;
|
|
||||||
},
|
|
||||||
|
|
||||||
fft(real, imaginary, inverse = false) {
|
|
||||||
const size = real.length;
|
|
||||||
this.reverseBits(real, imaginary);
|
|
||||||
const { cos, sin } = this.twiddles(size);
|
|
||||||
|
|
||||||
for (let span = 2; span <= size; span <<= 1) {
|
|
||||||
const half = span >> 1;
|
|
||||||
const stride = size / span;
|
|
||||||
|
|
||||||
for (let base = 0; base < size; base += span) {
|
|
||||||
for (let k = 0; k < half; k++) {
|
|
||||||
const twiddle = k * stride;
|
|
||||||
const wReal = cos[twiddle];
|
|
||||||
const wImaginary = inverse ? -sin[twiddle] : sin[twiddle];
|
|
||||||
|
|
||||||
const lower = base + k;
|
|
||||||
const upper = lower + half;
|
|
||||||
const productReal = real[upper] * wReal - imaginary[upper] * wImaginary;
|
|
||||||
const productImaginary = real[upper] * wImaginary + imaginary[upper] * wReal;
|
|
||||||
|
|
||||||
real[upper] = real[lower] - productReal;
|
|
||||||
imaginary[upper] = imaginary[lower] - productImaginary;
|
|
||||||
real[lower] += productReal;
|
|
||||||
imaginary[lower] += productImaginary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inverse) return { real, imaginary };
|
|
||||||
|
|
||||||
for (let i = 0; i < size; i++) {
|
|
||||||
real[i] /= size;
|
|
||||||
imaginary[i] /= size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { real, imaginary };
|
|
||||||
},
|
|
||||||
|
|
||||||
reverseBits(real, imaginary) {
|
|
||||||
const size = real.length;
|
|
||||||
|
|
||||||
for (let i = 1, j = 0; i < size; i++) {
|
|
||||||
let bit = size >> 1;
|
|
||||||
for (; j & bit; bit >>= 1) j ^= bit;
|
|
||||||
j ^= bit;
|
|
||||||
if (i >= j) continue;
|
|
||||||
|
|
||||||
[real[i], real[j]] = [real[j], real[i]];
|
|
||||||
[imaginary[i], imaginary[j]] = [imaginary[j], imaginary[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { real, imaginary };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matched-filter envelope of a recording against a template, via FFT cross-correlation.
|
|
||||||
* Negative frequencies are dropped so the result is the analytic envelope rather than a burst
|
|
||||||
* oscillating at the sweep frequency — an oscillating peak defeats sub-sample interpolation
|
|
||||||
* and makes first-arrival detection jitter by half a carrier period.
|
|
||||||
*/
|
|
||||||
matchedFilterEnvelope(recording, template) {
|
|
||||||
const size = this.nextPowerOfTwo(recording.length + template.length);
|
|
||||||
const recordingReal = new Float64Array(size);
|
|
||||||
const recordingImaginary = new Float64Array(size);
|
|
||||||
const templateReal = new Float64Array(size);
|
|
||||||
const templateImaginary = new Float64Array(size);
|
|
||||||
|
|
||||||
recordingReal.set(recording);
|
|
||||||
templateReal.set(template);
|
|
||||||
this.fft(recordingReal, recordingImaginary);
|
|
||||||
this.fft(templateReal, templateImaginary);
|
|
||||||
|
|
||||||
const analyticReal = new Float64Array(size);
|
|
||||||
const analyticImaginary = new Float64Array(size);
|
|
||||||
const half = size >> 1;
|
|
||||||
|
|
||||||
for (let i = 0; i <= half; i++) {
|
|
||||||
const gain = i === 0 || i === half ? 1 : 2;
|
|
||||||
analyticReal[i] = gain * (recordingReal[i] * templateReal[i] + recordingImaginary[i] * templateImaginary[i]);
|
|
||||||
analyticImaginary[i] = gain * (recordingImaginary[i] * templateReal[i] - recordingReal[i] * templateImaginary[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fft(analyticReal, analyticImaginary, true);
|
|
||||||
|
|
||||||
const envelope = new Float32Array(recording.length);
|
|
||||||
for (let i = 0; i < envelope.length; i++)
|
|
||||||
envelope[i] = Math.sqrt(analyticReal[i] * analyticReal[i] + analyticImaginary[i] * analyticImaginary[i]);
|
|
||||||
|
|
||||||
return envelope;
|
|
||||||
},
|
|
||||||
|
|
||||||
maxInRange(values, start, end) {
|
|
||||||
let index = start;
|
|
||||||
let value = -Infinity;
|
|
||||||
|
|
||||||
for (let i = start; i < end; i++) {
|
|
||||||
if (values[i] <= value) continue;
|
|
||||||
value = values[i];
|
|
||||||
index = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { index, value };
|
|
||||||
},
|
|
||||||
|
|
||||||
medianInRange(values, start, end, sampleLimit = 2048) {
|
|
||||||
const span = end - start;
|
|
||||||
if (span <= 0) return 0;
|
|
||||||
|
|
||||||
const stride = Math.max(1, Math.floor(span / sampleLimit));
|
|
||||||
const sampled = [];
|
|
||||||
for (let i = start; i < end; i += stride) sampled.push(values[i]);
|
|
||||||
|
|
||||||
sampled.sort((left, right) => left - right);
|
|
||||||
return sampled[sampled.length >> 1];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sub-sample peak position by fitting a parabola through the peak and its neighbours. One
|
|
||||||
* tenth of a sample is 0.7mm of range at 48kHz, so this is most of the accuracy for free.
|
|
||||||
*/
|
|
||||||
refinePeakIndex(envelope, index) {
|
|
||||||
if (index <= 0 || index >= envelope.length - 1) return index;
|
|
||||||
|
|
||||||
const before = envelope[index - 1];
|
|
||||||
const peak = envelope[index];
|
|
||||||
const after = envelope[index + 1];
|
|
||||||
const curvature = before - 2 * peak + after;
|
|
||||||
if (curvature === 0) return index;
|
|
||||||
|
|
||||||
const offset = (0.5 * (before - after)) / curvature;
|
|
||||||
return index + Math.max(-1, Math.min(1, offset));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First arrival in a window, not the loudest one. A reflection off a wall or table often
|
|
||||||
* comes back louder than the direct path, and only the direct path is the distance.
|
|
||||||
*/
|
|
||||||
findFirstPeak(envelope, options = {}) {
|
|
||||||
const start = Math.max(0, Math.floor(options.start ?? 0));
|
|
||||||
const end = Math.min(envelope.length, Math.ceil(options.end ?? envelope.length));
|
|
||||||
if (end - start < 8) return null;
|
|
||||||
|
|
||||||
const loudest = this.maxInRange(envelope, start, end);
|
|
||||||
const noiseFloor = this.medianInRange(envelope, start, end);
|
|
||||||
const snr = noiseFloor > 0 ? loudest.value / noiseFloor : Infinity;
|
|
||||||
if (snr < (options.minSnr ?? 4)) return null;
|
|
||||||
|
|
||||||
// Held above the noise floor so sidelobes cannot trigger it, but well below the loudest
|
|
||||||
// arrival so that a reflection several times louder than the direct path cannot mask it.
|
|
||||||
const threshold = Math.max(
|
|
||||||
noiseFloor * (options.noiseMultiple ?? 6),
|
|
||||||
loudest.value * (options.relativeThreshold ?? 0.15)
|
|
||||||
);
|
|
||||||
|
|
||||||
let crossing = start;
|
|
||||||
while (crossing < end && envelope[crossing] < threshold) crossing++;
|
|
||||||
if (crossing >= end) return null;
|
|
||||||
|
|
||||||
const lobeEnd = Math.min(end, crossing + (options.lobeSamples ?? 64));
|
|
||||||
const arrival = this.maxInRange(envelope, crossing, lobeEnd);
|
|
||||||
|
|
||||||
return { index: this.refinePeakIndex(envelope, arrival.index), amplitude: arrival.value, snr };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate every chirp of one round in a single device's recording.
|
|
||||||
*
|
|
||||||
* The device's own chirp is the anchor: it is always present and always the loudest thing in
|
|
||||||
* the recording, and its position absorbs this device's own output and input latency. Every
|
|
||||||
* other slot is then searched relative to that anchor, so no clock agreement between devices
|
|
||||||
* is required — only that the chirps stay in their slots.
|
|
||||||
*/
|
|
||||||
detectSlotPeaks({
|
|
||||||
envelope,
|
|
||||||
slotCount,
|
|
||||||
slotSamples,
|
|
||||||
ownSlot,
|
|
||||||
ownSearchStart,
|
|
||||||
ownSearchSamples,
|
|
||||||
slotHints = null,
|
|
||||||
peakOptions = {}
|
|
||||||
}) {
|
|
||||||
const own = this.findFirstPeak(envelope, {
|
|
||||||
...peakOptions,
|
|
||||||
start: ownSearchStart,
|
|
||||||
end: ownSearchStart + ownSearchSamples
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!own) return null;
|
|
||||||
|
|
||||||
const anchor = own.index - ownSlot * slotSamples;
|
|
||||||
const pad = Math.floor(slotSamples * 0.45);
|
|
||||||
const peaks = new Array(slotCount).fill(null);
|
|
||||||
peaks[ownSlot] = own;
|
|
||||||
|
|
||||||
for (let slot = 0; slot < slotCount; slot++) {
|
|
||||||
if (slot === ownSlot) continue;
|
|
||||||
|
|
||||||
const centre = anchor + slot * slotSamples + (slotHints?.[slot] ?? 0);
|
|
||||||
peaks[slot] = this.findFirstPeak(envelope, {
|
|
||||||
...peakOptions,
|
|
||||||
start: centre - pad,
|
|
||||||
end: centre + pad
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { anchor, peaks };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distance between two devices from four arrival indices, each measured inside the recording
|
|
||||||
* of the device that made it. Clock offset and audio-pipeline latency appear once with each
|
|
||||||
* sign and cancel; the devices' own speaker-to-microphone spacing does not, and is added back.
|
|
||||||
*/
|
|
||||||
pairDistance({ a1, a2, b1, b2, sampleRate, sampleRateA, sampleRateB, speedOfSound, epsilonA = 0, epsilonB = 0 }) {
|
|
||||||
// Each interval is converted to seconds in its own device's sample rate before the two are
|
|
||||||
// subtracted: a device that hands back 44100 instead of 48000 would otherwise contribute
|
|
||||||
// its interval in the wrong unit.
|
|
||||||
const intervalA = (a2 - a1) / (sampleRateA ?? sampleRate);
|
|
||||||
const intervalB = (b2 - b1) / (sampleRateB ?? sampleRate);
|
|
||||||
return ((intervalA - intervalB) / 2) * speedOfSound + (epsilonA + epsilonB) / 2;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Symmetric distance matrix from one round of reports. Entries stay null where either device
|
|
||||||
* failed to hear one of the four chirps the pair needs.
|
|
||||||
*/
|
|
||||||
buildDistanceMatrix(reports, { speedOfSound = 343, maxDistance = 40 } = {}) {
|
|
||||||
const count = reports.length;
|
|
||||||
const matrix = Array.from({ length: count }, () => new Array(count).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
matrix[i][i] = 0;
|
|
||||||
|
|
||||||
for (let j = i + 1; j < count; j++) {
|
|
||||||
const distance = this.distanceBetween(reports[i], reports[j], speedOfSound);
|
|
||||||
if (distance === null || distance < -1 || distance > maxDistance) continue;
|
|
||||||
|
|
||||||
matrix[i][j] = Math.max(0, distance);
|
|
||||||
matrix[j][i] = matrix[i][j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix;
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceBetween(deviceA, deviceB, speedOfSound) {
|
|
||||||
const a1 = deviceA.peaks[deviceA.slot];
|
|
||||||
const a2 = deviceA.peaks[deviceB.slot];
|
|
||||||
const b1 = deviceB.peaks[deviceA.slot];
|
|
||||||
const b2 = deviceB.peaks[deviceB.slot];
|
|
||||||
if (a1 === null || a2 === null || b1 === null || b2 === null) return null;
|
|
||||||
|
|
||||||
return this.pairDistance({
|
|
||||||
a1,
|
|
||||||
a2,
|
|
||||||
b1,
|
|
||||||
b2,
|
|
||||||
sampleRateA: deviceA.sampleRate,
|
|
||||||
sampleRateB: deviceB.sampleRate,
|
|
||||||
speedOfSound,
|
|
||||||
epsilonA: deviceA.epsilon ?? 0,
|
|
||||||
epsilonB: deviceB.epsilon ?? 0
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Largest set of devices linked by measured distances. Anything outside it cannot be placed
|
|
||||||
* relative to the others, and leaving it in makes the completed matrix infinite.
|
|
||||||
*/
|
|
||||||
largestConnectedComponent(matrix) {
|
|
||||||
const unvisited = new Set(matrix.map((_, index) => index));
|
|
||||||
let largest = [];
|
|
||||||
|
|
||||||
while (unvisited.size > 0) {
|
|
||||||
const component = [];
|
|
||||||
const queue = [unvisited.values().next().value];
|
|
||||||
unvisited.delete(queue[0]);
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.pop();
|
|
||||||
component.push(current);
|
|
||||||
|
|
||||||
for (const next of unvisited)
|
|
||||||
if (matrix[current][next] !== null) {
|
|
||||||
unvisited.delete(next);
|
|
||||||
queue.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component.length > largest.length) largest = component;
|
|
||||||
}
|
|
||||||
|
|
||||||
return largest.sort((left, right) => left - right);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop devices whose distances are geometrically impossible. One device reporting a bad peak
|
|
||||||
* distorts the whole layout, so the worst triangle-inequality offender is removed and the
|
|
||||||
* check repeated. Below four devices there is no redundancy left and nothing can be checked.
|
|
||||||
*/
|
|
||||||
rejectOutliers(matrix, candidates, tolerance = 0.5) {
|
|
||||||
const keep = [...candidates];
|
|
||||||
|
|
||||||
while (keep.length > 3) {
|
|
||||||
const violations = this.countTriangleViolations(matrix, keep, tolerance);
|
|
||||||
const worst = violations.reduce((best, count, index) => (count > violations[best] ? index : best), 0);
|
|
||||||
if (violations[worst] === 0) break;
|
|
||||||
|
|
||||||
keep.splice(worst, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return keep;
|
|
||||||
},
|
|
||||||
|
|
||||||
submatrix(matrix, indices) {
|
|
||||||
return indices.map(row => indices.map(column => matrix[row][column]));
|
|
||||||
},
|
|
||||||
|
|
||||||
countTriangleViolations(matrix, keep, tolerance) {
|
|
||||||
const violations = new Array(keep.length).fill(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < keep.length; i++) {
|
|
||||||
for (let j = i + 1; j < keep.length; j++) {
|
|
||||||
for (let k = j + 1; k < keep.length; k++) {
|
|
||||||
const sides = [matrix[keep[i]][keep[j]], matrix[keep[j]][keep[k]], matrix[keep[i]][keep[k]]];
|
|
||||||
if (sides.some(side => side === null)) continue;
|
|
||||||
|
|
||||||
const longest = Math.max(...sides);
|
|
||||||
const perimeter = sides.reduce((sum, side) => sum + side, 0);
|
|
||||||
if (longest <= perimeter - longest + tolerance) continue;
|
|
||||||
|
|
||||||
violations[i]++;
|
|
||||||
violations[j]++;
|
|
||||||
violations[k]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return violations;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Fill gaps with the shortest known path between the two devices so MDS gets a full matrix. */
|
|
||||||
completeMatrix(matrix) {
|
|
||||||
const count = matrix.length;
|
|
||||||
const filled = matrix.map(row => row.map(value => (value === null ? Infinity : value)));
|
|
||||||
|
|
||||||
for (let via = 0; via < count; via++)
|
|
||||||
for (let i = 0; i < count; i++)
|
|
||||||
for (let j = 0; j < count; j++)
|
|
||||||
filled[i][j] = Math.min(filled[i][j], filled[i][via] + filled[via][j]);
|
|
||||||
|
|
||||||
return filled;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Jacobi eigendecomposition of a symmetric matrix. Returns eigenvalues and column vectors. */
|
|
||||||
symmetricEigen(matrix, maxSweeps = 100, tolerance = 1e-14) {
|
|
||||||
const count = matrix.length;
|
|
||||||
const working = matrix.map(row => Float64Array.from(row));
|
|
||||||
const vectors = Array.from({ length: count }, (_, i) => {
|
|
||||||
const column = new Float64Array(count);
|
|
||||||
column[i] = 1;
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let sweep = 0; sweep < maxSweeps; sweep++) {
|
|
||||||
if (this.offDiagonalMagnitude(working) < tolerance) break;
|
|
||||||
|
|
||||||
for (let p = 0; p < count - 1; p++)
|
|
||||||
for (let q = p + 1; q < count; q++)
|
|
||||||
this.rotateOut(working, vectors, p, q);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
values: working.map((row, i) => row[i]),
|
|
||||||
vectors
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
offDiagonalMagnitude(matrix) {
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++)
|
|
||||||
for (let j = i + 1; j < matrix.length; j++) total += matrix[i][j] * matrix[i][j];
|
|
||||||
|
|
||||||
return total;
|
|
||||||
},
|
|
||||||
|
|
||||||
rotateOut(matrix, vectors, p, q) {
|
|
||||||
if (Math.abs(matrix[p][q]) < 1e-300) return matrix;
|
|
||||||
|
|
||||||
const theta = (matrix[q][q] - matrix[p][p]) / (2 * matrix[p][q]);
|
|
||||||
const sign = theta >= 0 ? 1 : -1;
|
|
||||||
const tangent = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
|
||||||
const cosine = 1 / Math.sqrt(tangent * tangent + 1);
|
|
||||||
const sine = tangent * cosine;
|
|
||||||
const count = matrix.length;
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = matrix[k][p];
|
|
||||||
const right = matrix[k][q];
|
|
||||||
matrix[k][p] = cosine * left - sine * right;
|
|
||||||
matrix[k][q] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = matrix[p][k];
|
|
||||||
const right = matrix[q][k];
|
|
||||||
matrix[p][k] = cosine * left - sine * right;
|
|
||||||
matrix[q][k] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = vectors[k][p];
|
|
||||||
const right = vectors[k][q];
|
|
||||||
vectors[k][p] = cosine * left - sine * right;
|
|
||||||
vectors[k][q] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classical multidimensional scaling: coordinates whose pairwise distances best reproduce the
|
|
||||||
* matrix. The result is only defined up to rotation, translation and mirroring.
|
|
||||||
*/
|
|
||||||
classicalMds(distances, dimensions = 2) {
|
|
||||||
const count = distances.length;
|
|
||||||
const squared = distances.map(row => row.map(value => value * value));
|
|
||||||
const rowMeans = squared.map(row => row.reduce((sum, value) => sum + value, 0) / count);
|
|
||||||
const grandMean = rowMeans.reduce((sum, value) => sum + value, 0) / count;
|
|
||||||
|
|
||||||
const centred = squared.map((row, i) => row.map((value, j) => -0.5 * (value - rowMeans[i] - rowMeans[j] + grandMean)));
|
|
||||||
const { values, vectors } = this.symmetricEigen(centred);
|
|
||||||
const order = values
|
|
||||||
.map((value, index) => ({ value, index }))
|
|
||||||
.sort((left, right) => right.value - left.value)
|
|
||||||
.slice(0, dimensions);
|
|
||||||
|
|
||||||
return Array.from({ length: count }, (_, i) =>
|
|
||||||
order.map(({ value, index }) => vectors[i][index] * Math.sqrt(Math.max(0, value)))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate, mirror and translate a constellation onto a reference layout. Without this, every
|
|
||||||
* solve returns an arbitrary orientation and the display spins and flips between updates.
|
|
||||||
*/
|
|
||||||
alignToReference(points, reference) {
|
|
||||||
if (!reference || reference.length !== points.length || points.length === 0) return points;
|
|
||||||
|
|
||||||
const pointCentre = this.centroid(points);
|
|
||||||
const referenceCentre = this.centroid(reference);
|
|
||||||
let best = null;
|
|
||||||
|
|
||||||
for (const mirror of [1, -1]) {
|
|
||||||
const candidate = this.rotateOnto(points, reference, pointCentre, referenceCentre, mirror);
|
|
||||||
if (!best || candidate.residual < best.residual) best = candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return best.points;
|
|
||||||
},
|
|
||||||
|
|
||||||
centroid(points) {
|
|
||||||
const sum = points.reduce((total, [x, y]) => [total[0] + x, total[1] + y], [0, 0]);
|
|
||||||
return [sum[0] / points.length, sum[1] / points.length];
|
|
||||||
},
|
|
||||||
|
|
||||||
rotateOnto(points, reference, pointCentre, referenceCentre, mirror) {
|
|
||||||
let sineTerm = 0;
|
|
||||||
let cosineTerm = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const px = (points[i][0] - pointCentre[0]) * mirror;
|
|
||||||
const py = points[i][1] - pointCentre[1];
|
|
||||||
const qx = reference[i][0] - referenceCentre[0];
|
|
||||||
const qy = reference[i][1] - referenceCentre[1];
|
|
||||||
sineTerm += px * qy - py * qx;
|
|
||||||
cosineTerm += px * qx + py * qy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = Math.atan2(sineTerm, cosineTerm);
|
|
||||||
const cosine = Math.cos(angle);
|
|
||||||
const sine = Math.sin(angle);
|
|
||||||
let residual = 0;
|
|
||||||
|
|
||||||
const aligned = points.map((point, i) => {
|
|
||||||
const px = (point[0] - pointCentre[0]) * mirror;
|
|
||||||
const py = point[1] - pointCentre[1];
|
|
||||||
const x = px * cosine - py * sine + referenceCentre[0];
|
|
||||||
const y = px * sine + py * cosine + referenceCentre[1];
|
|
||||||
residual += (x - reference[i][0]) ** 2 + (y - reference[i][1]) ** 2;
|
|
||||||
return [x, y];
|
|
||||||
});
|
|
||||||
|
|
||||||
return { points: aligned, residual };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full solve for one round: distances, connectivity, outlier rejection, then a constellation
|
|
||||||
* aligned onto the previous frame.
|
|
||||||
*
|
|
||||||
* <c>previousPoints</c> is indexed by report position, with null for devices that were dropped
|
|
||||||
* last round, so alignment survives devices coming and going.
|
|
||||||
*/
|
|
||||||
solveRound(reports, { speedOfSound = 343, previousPoints = null, tolerance = 0.5 } = {}) {
|
|
||||||
const matrix = this.buildDistanceMatrix(reports, { speedOfSound });
|
|
||||||
const connected = this.largestConnectedComponent(matrix);
|
|
||||||
const keep = this.rejectOutliers(matrix, connected, tolerance);
|
|
||||||
const points = keep.length >= 2 ? this.classicalMds(this.completeMatrix(this.submatrix(matrix, keep))) : [];
|
|
||||||
const reference = previousPoints ? keep.map(index => previousPoints[index]) : null;
|
|
||||||
const alignable = reference?.length === points.length && reference.every(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
matrix,
|
|
||||||
keep,
|
|
||||||
points: alignable ? this.alignToReference(points, reference) : points
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") window.EchoDsp = EchoDsp;
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/*
|
|
||||||
* Round lifecycle: join a room, chirp when told to, measure every chirp in our own recording, and
|
|
||||||
* report the frame indices. Peaks are reported relative to the analysis window rather than to an
|
|
||||||
* absolute frame — every measurement is a difference within one device's own recording, so a
|
|
||||||
* constant per-device origin cancels.
|
|
||||||
*
|
|
||||||
* Both the chirp and the analysis are driven by the audio clock rather than by timers: a hidden tab
|
|
||||||
* has its timers throttled to roughly once a second, which is long enough to put its chirp in
|
|
||||||
* another device's slot and turn the whole round into nonsense.
|
|
||||||
*/
|
|
||||||
const EchoSession = {
|
|
||||||
LEAD_IN_SECONDS: 0.25,
|
|
||||||
TEMPERATURE_CELSIUS: 20,
|
|
||||||
MAX_OUTPUT_LATENCY_SECONDS: 0.35,
|
|
||||||
CHIRP: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
|
|
||||||
|
|
||||||
CLOCK_SAMPLES: 5,
|
|
||||||
LATE_TOLERANCE_SECONDS: 0.05,
|
|
||||||
|
|
||||||
connection: null,
|
|
||||||
deviceId: null,
|
|
||||||
roomCode: null,
|
|
||||||
serverOffsetMs: 0,
|
|
||||||
skippedRounds: 0,
|
|
||||||
devices: [],
|
|
||||||
chirp: null,
|
|
||||||
pending: null,
|
|
||||||
lastRound: null,
|
|
||||||
hintsByDevice: {},
|
|
||||||
previousPoints: null,
|
|
||||||
epsilon: 0.08,
|
|
||||||
onUpdate: () => {},
|
|
||||||
|
|
||||||
async join(roomCode, displayName, { onUpdate }) {
|
|
||||||
this.onUpdate = onUpdate ?? this.onUpdate;
|
|
||||||
const audio = await EchoAudio.start();
|
|
||||||
this.chirp = EchoDsp.makeChirp({ sampleRate: audio.sampleRate, ...this.CHIRP });
|
|
||||||
EchoAudio.onSamples = frame => this.analyseIfCaptured(frame);
|
|
||||||
|
|
||||||
this.connection = new signalR.HubConnectionBuilder().withUrl("/echoHub").withAutomaticReconnect().build();
|
|
||||||
this.connection.on("RoomChanged", room => this.handleRoomChanged(room));
|
|
||||||
this.connection.on("RoundStarting", schedule => this.handleRoundStarting(schedule));
|
|
||||||
this.connection.on("RoundComplete", result => this.handleRoundComplete(result));
|
|
||||||
await this.connection.start();
|
|
||||||
|
|
||||||
const joined = await this.connection.invoke("JoinRoom", roomCode, displayName, audio.sampleRate);
|
|
||||||
this.deviceId = joined.deviceId;
|
|
||||||
this.roomCode = joined.roomCode;
|
|
||||||
this.devices = joined.room.devices;
|
|
||||||
this.serverOffsetMs = await this.estimateServerOffset();
|
|
||||||
|
|
||||||
return { ...joined, audio, serverOffsetMs: this.serverOffsetMs };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offset from the server clock, by the usual round-trip midpoint. Only needs to be good enough
|
|
||||||
* to identify which chirp belongs to which slot, which is tens of milliseconds — the ranging
|
|
||||||
* itself never uses a shared clock.
|
|
||||||
*/
|
|
||||||
async estimateServerOffset() {
|
|
||||||
const offsets = [];
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < this.CLOCK_SAMPLES; attempt++) {
|
|
||||||
const sent = Date.now();
|
|
||||||
const serverNow = await this.connection.invoke("ServerTime");
|
|
||||||
const received = Date.now();
|
|
||||||
offsets.push(serverNow - (sent + received) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
offsets.sort((left, right) => left - right);
|
|
||||||
return offsets[offsets.length >> 1];
|
|
||||||
},
|
|
||||||
|
|
||||||
serverNowMs() {
|
|
||||||
return Date.now() + this.serverOffsetMs;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleRoomChanged(room) {
|
|
||||||
this.devices = room.devices;
|
|
||||||
this.previousPoints = null;
|
|
||||||
this.hintsByDevice = {};
|
|
||||||
this.onUpdate({ kind: "room", room });
|
|
||||||
return room;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule our chirp for our slot on the audio clock, and record which frames this round will
|
|
||||||
* occupy so the analysis can start the moment that audio has actually been captured.
|
|
||||||
*/
|
|
||||||
handleRoundStarting(schedule) {
|
|
||||||
const ownSlot = schedule.slotOrder.indexOf(this.deviceId);
|
|
||||||
if (ownSlot < 0 || !EchoAudio.context) return null;
|
|
||||||
|
|
||||||
const sampleRate = EchoAudio.context.sampleRate;
|
|
||||||
const slotSeconds = schedule.slotMilliseconds / 1000;
|
|
||||||
const secondsUntilStart = (schedule.startsAtUnixMs - this.serverNowMs()) / 1000;
|
|
||||||
const roundStartTime = EchoAudio.context.currentTime + secondsUntilStart;
|
|
||||||
const playAtTime = roundStartTime + ownSlot * slotSeconds;
|
|
||||||
|
|
||||||
// Sitting a round out is far better than chirping late: a chirp in the wrong slot is
|
|
||||||
// attributed to the wrong device and ruins the round for everyone, whereas a missing chirp
|
|
||||||
// costs only this pair, this round.
|
|
||||||
if (playAtTime < EchoAudio.context.currentTime - this.LATE_TOLERANCE_SECONDS) {
|
|
||||||
this.skippedRounds++;
|
|
||||||
this.onUpdate({ kind: "skipped", schedule, lateBySeconds: EchoAudio.context.currentTime - playAtTime });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledFrame = EchoAudio.play(this.chirp, playAtTime);
|
|
||||||
|
|
||||||
const round = {
|
|
||||||
schedule,
|
|
||||||
ownSlot,
|
|
||||||
sampleRate,
|
|
||||||
scheduledFrame,
|
|
||||||
slotSamples: Math.round(slotSeconds * sampleRate),
|
|
||||||
leadInSamples: Math.round(this.LEAD_IN_SECONDS * sampleRate),
|
|
||||||
startFrame: EchoAudio.frameAt(roundStartTime),
|
|
||||||
outputLatencyMs: Math.round(EchoAudio.outputLatencySeconds() * 1000),
|
|
||||||
captureLagMs: Math.round(EchoAudio.captureLagSeconds() * 1000)
|
|
||||||
};
|
|
||||||
|
|
||||||
round.windowStart = round.startFrame - round.leadInSamples;
|
|
||||||
round.windowLength =
|
|
||||||
round.leadInSamples +
|
|
||||||
schedule.slotOrder.length * round.slotSamples +
|
|
||||||
Math.round((schedule.tailMilliseconds / 1000) * sampleRate);
|
|
||||||
|
|
||||||
this.pending = round;
|
|
||||||
this.onUpdate({ kind: "roundStarting", round });
|
|
||||||
return round;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run as soon as the round's audio exists in the ring. Driven by captured audio rather than a
|
|
||||||
* timer so a throttled tab still analyses on time.
|
|
||||||
*/
|
|
||||||
analyseIfCaptured(highestFrame) {
|
|
||||||
const round = this.pending;
|
|
||||||
if (!round) return null;
|
|
||||||
if (highestFrame < round.windowStart + round.windowLength) return null;
|
|
||||||
|
|
||||||
this.pending = null;
|
|
||||||
return this.analyse(round);
|
|
||||||
},
|
|
||||||
|
|
||||||
analyse(round) {
|
|
||||||
this.lastRound = round;
|
|
||||||
const recording = EchoAudio.read(round.windowStart, round.windowLength);
|
|
||||||
if (!recording) return null;
|
|
||||||
|
|
||||||
const envelope = EchoDsp.matchedFilterEnvelope(recording, this.chirp);
|
|
||||||
const detected = EchoDsp.detectSlotPeaks({
|
|
||||||
envelope,
|
|
||||||
slotCount: round.schedule.slotOrder.length,
|
|
||||||
slotSamples: round.slotSamples,
|
|
||||||
ownSlot: round.ownSlot,
|
|
||||||
// Anchored on the frame the chirp was scheduled for, so the only unknown left is how
|
|
||||||
// long the speaker takes to actually emit it.
|
|
||||||
ownSearchStart: round.scheduledFrame - round.windowStart,
|
|
||||||
ownSearchSamples: Math.round((this.MAX_OUTPUT_LATENCY_SECONDS + this.CHIRP.durationSeconds) * round.sampleRate),
|
|
||||||
slotHints: this.hintsFor(round.schedule)
|
|
||||||
});
|
|
||||||
|
|
||||||
const diagnostics = this.describeRound(round, detected);
|
|
||||||
this.onUpdate({ kind: "envelope", envelope, detected, round, diagnostics });
|
|
||||||
if (!detected) return null;
|
|
||||||
|
|
||||||
this.rememberHints(round.schedule, detected, round.slotSamples);
|
|
||||||
return this.report(round, detected);
|
|
||||||
},
|
|
||||||
|
|
||||||
describeRound(round, detected) {
|
|
||||||
const millisecondsPer = 1000 / round.sampleRate;
|
|
||||||
|
|
||||||
return {
|
|
||||||
captureLagMs: round.captureLagMs,
|
|
||||||
outputLatencyMs: round.outputLatencyMs,
|
|
||||||
serverOffsetMs: Math.round(this.serverOffsetMs),
|
|
||||||
skippedRounds: this.skippedRounds,
|
|
||||||
clipping: EchoAudio.peakAmplitude >= 0.99,
|
|
||||||
inputPeak: Number(EchoAudio.peakAmplitude.toFixed(3)),
|
|
||||||
ownFound: Boolean(detected?.peaks[round.ownSlot]),
|
|
||||||
slots: round.schedule.slotOrder.map((deviceId, slot) => ({
|
|
||||||
deviceId,
|
|
||||||
own: slot === round.ownSlot,
|
|
||||||
residualMs: detected?.peaks[slot]
|
|
||||||
? Math.round((detected.peaks[slot].index - (detected.anchor + slot * round.slotSamples)) * millisecondsPer)
|
|
||||||
: null,
|
|
||||||
snr: detected?.peaks[slot] ? Math.round(detected.peaks[slot].snr) : null
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
report(round, detected) {
|
|
||||||
return this.connection.invoke("ReportRound", {
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
roundId: round.schedule.roundId,
|
|
||||||
slot: round.ownSlot,
|
|
||||||
sampleRate: round.sampleRate,
|
|
||||||
epsilon: this.epsilon,
|
|
||||||
peaks: detected.peaks.map(peak => peak?.index ?? null)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where each device's chirp actually landed last round, relative to where the slot said it
|
|
||||||
* would. Output latency differs by tens of milliseconds per device and is stable, so carrying
|
|
||||||
* the residual forward keeps the search windows centred instead of merely wide.
|
|
||||||
*/
|
|
||||||
rememberHints(schedule, detected, slotSamples) {
|
|
||||||
schedule.slotOrder.forEach((deviceId, slot) => {
|
|
||||||
const peak = detected.peaks[slot];
|
|
||||||
if (!peak) return;
|
|
||||||
this.hintsByDevice[deviceId] = peak.index - (detected.anchor + slot * slotSamples);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.hintsByDevice;
|
|
||||||
},
|
|
||||||
|
|
||||||
hintsFor(schedule) {
|
|
||||||
return schedule.slotOrder.map(deviceId => this.hintsByDevice[deviceId] ?? 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleRoundComplete(result) {
|
|
||||||
const reports = result.reports.map(report => ({ ...report, peaks: report.peaks ?? [] }));
|
|
||||||
const solved = EchoDsp.solveRound(reports, {
|
|
||||||
speedOfSound: EchoDsp.speedOfSound(this.TEMPERATURE_CELSIUS),
|
|
||||||
previousPoints: this.previousPoints
|
|
||||||
});
|
|
||||||
|
|
||||||
this.previousPoints = this.pointsByReportIndex(reports, solved);
|
|
||||||
this.onUpdate({ kind: "solved", result, reports, solved, deviceId: this.deviceId });
|
|
||||||
return solved;
|
|
||||||
},
|
|
||||||
|
|
||||||
pointsByReportIndex(reports, solved) {
|
|
||||||
const points = new Array(reports.length).fill(null);
|
|
||||||
solved.keep.forEach((reportIndex, i) => {
|
|
||||||
points[reportIndex] = solved.points[i];
|
|
||||||
});
|
|
||||||
return points;
|
|
||||||
},
|
|
||||||
|
|
||||||
setEpsilon(metres) {
|
|
||||||
this.epsilon = metres;
|
|
||||||
localStorage.setItem("echo.epsilon", String(metres));
|
|
||||||
return this.epsilon;
|
|
||||||
},
|
|
||||||
|
|
||||||
loadEpsilon() {
|
|
||||||
const stored = Number(localStorage.getItem("echo.epsilon"));
|
|
||||||
this.epsilon = Number.isFinite(stored) && stored > 0 ? stored : 0.08;
|
|
||||||
return this.epsilon;
|
|
||||||
},
|
|
||||||
|
|
||||||
async leave() {
|
|
||||||
await this.connection?.invoke("LeaveRoom").catch(() => {});
|
|
||||||
await this.connection?.stop();
|
|
||||||
await EchoAudio.stop();
|
|
||||||
this.connection = null;
|
|
||||||
this.pending = null;
|
|
||||||
this.previousPoints = null;
|
|
||||||
this.hintsByDevice = {};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") window.EchoSession = EchoSession;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/*
|
|
||||||
* Virtual room for exercising the real ranging pipeline without microphones. Synthesizes what
|
|
||||||
* each device would have recorded — propagation delay, reflections, noise, per-device clock offset
|
|
||||||
* and unknown output latency — then runs the same EchoDsp code the page runs.
|
|
||||||
*/
|
|
||||||
const EchoSim = {
|
|
||||||
DEFAULTS: {
|
|
||||||
sampleRate: 48000,
|
|
||||||
slotSeconds: 0.4,
|
|
||||||
tailSeconds: 0.5,
|
|
||||||
speedOfSound: 343,
|
|
||||||
noiseAmplitude: 0.01,
|
|
||||||
referenceGain: 0.5,
|
|
||||||
minimumPathMetres: 0.25,
|
|
||||||
maximumOutputLatencySeconds: 0.2,
|
|
||||||
chirp: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
|
|
||||||
reflectionsPerPath: 2,
|
|
||||||
reflectionExtraRange: [0.4, 4.0],
|
|
||||||
reflectionGainRange: [0.2, 0.8],
|
|
||||||
seed: 20260730
|
|
||||||
},
|
|
||||||
|
|
||||||
randomGenerator(seed) {
|
|
||||||
let state = seed >>> 0;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
state = (state + 0x6d2b79f5) >>> 0;
|
|
||||||
let mixed = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
||||||
mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed;
|
|
||||||
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
separation(first, second) {
|
|
||||||
return Math.hypot(first[0] - second[0], first[1] - second[1]);
|
|
||||||
},
|
|
||||||
|
|
||||||
buildConfiguration(overrides = {}) {
|
|
||||||
const config = { ...this.DEFAULTS, ...overrides };
|
|
||||||
const count = config.positions.length;
|
|
||||||
|
|
||||||
config.chirp = { ...this.DEFAULTS.chirp, ...(overrides.chirp ?? {}) };
|
|
||||||
config.epsilon ??= new Array(count).fill(0.05);
|
|
||||||
config.clockOffsets ??= config.positions.map((_, i) => i * 7919);
|
|
||||||
config.outputLatencies ??= config.positions.map((_, i) => 0.02 + 0.03 * i);
|
|
||||||
config.scheduleJitter ??= config.positions.map((_, i) => 0.004 * i);
|
|
||||||
config.reflections ??= this.buildReflectionTable(config);
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multipath for every source-to-listener path independently. Giving every path the same echo
|
|
||||||
* would be worthless as a test: an identical bias on all four arrivals cancels out of the
|
|
||||||
* range formula, so a uniform echo model hides exactly the error it is supposed to expose.
|
|
||||||
*/
|
|
||||||
buildReflectionTable(config) {
|
|
||||||
const random = this.randomGenerator(config.seed ^ 0x5f3759df);
|
|
||||||
const spread = (range, value) => range[0] + value * (range[1] - range[0]);
|
|
||||||
|
|
||||||
return config.positions.map(() =>
|
|
||||||
config.positions.map(() =>
|
|
||||||
Array.from({ length: config.reflectionsPerPath }, () => ({
|
|
||||||
extraMetres: spread(config.reflectionExtraRange, random()),
|
|
||||||
gain: spread(config.reflectionGainRange, random())
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
reflectionsFor(config, source, listener) {
|
|
||||||
return Array.isArray(config.reflections[0]) ? config.reflections[source][listener] : config.reflections;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** One recording per device, plus the index each device believes it started playing at. */
|
|
||||||
synthesizeRound(config) {
|
|
||||||
const { positions, sampleRate, slotSeconds, tailSeconds } = config;
|
|
||||||
const random = this.randomGenerator(config.seed);
|
|
||||||
const template = EchoDsp.makeChirp({ sampleRate, ...config.chirp });
|
|
||||||
const maximumOffset = Math.max(...config.clockOffsets);
|
|
||||||
const length = Math.ceil((positions.length * slotSeconds + tailSeconds) * sampleRate) + maximumOffset;
|
|
||||||
|
|
||||||
const devices = positions.map((_, index) => ({
|
|
||||||
recording: this.noiseBuffer(length, config.noiseAmplitude, random),
|
|
||||||
ownSearchStart: Math.round((index * slotSeconds + config.scheduleJitter[index]) * sampleRate) + config.clockOffsets[index]
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (let source = 0; source < positions.length; source++)
|
|
||||||
for (let listener = 0; listener < positions.length; listener++)
|
|
||||||
this.mixArrivals(devices[listener].recording, template, config, source, listener);
|
|
||||||
|
|
||||||
return { devices, template };
|
|
||||||
},
|
|
||||||
|
|
||||||
noiseBuffer(length, amplitude, random) {
|
|
||||||
const buffer = new Float32Array(length);
|
|
||||||
for (let i = 0; i < length; i++) buffer[i] = (random() * 2 - 1) * amplitude;
|
|
||||||
return buffer;
|
|
||||||
},
|
|
||||||
|
|
||||||
mixArrivals(recording, template, config, source, listener) {
|
|
||||||
const emissionSeconds =
|
|
||||||
source * config.slotSeconds + config.scheduleJitter[source] + config.outputLatencies[source];
|
|
||||||
const directMetres =
|
|
||||||
source === listener ? config.epsilon[source] : this.separation(config.positions[source], config.positions[listener]);
|
|
||||||
|
|
||||||
const paths = [
|
|
||||||
{ metres: directMetres, gain: 1 },
|
|
||||||
...this.reflectionsFor(config, source, listener).map(({ extraMetres, gain }) => ({
|
|
||||||
metres: directMetres + extraMetres,
|
|
||||||
gain
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
const arrival = emissionSeconds + path.metres / config.speedOfSound;
|
|
||||||
const amplitude =
|
|
||||||
(config.referenceGain / Math.max(path.metres, config.minimumPathMetres)) * path.gain;
|
|
||||||
this.addAt(recording, template, Math.round(arrival * config.sampleRate) + config.clockOffsets[listener], amplitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
return recording;
|
|
||||||
},
|
|
||||||
|
|
||||||
addAt(recording, template, offset, amplitude) {
|
|
||||||
const start = Math.max(0, offset);
|
|
||||||
const end = Math.min(recording.length, offset + template.length);
|
|
||||||
|
|
||||||
for (let i = start; i < end; i++) recording[i] += template[i - offset] * amplitude;
|
|
||||||
|
|
||||||
return recording;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Run every device's recording through detection and return one report per device. */
|
|
||||||
detectAll({ devices, template }, config) {
|
|
||||||
const slotSamples = Math.round(config.slotSeconds * config.sampleRate);
|
|
||||||
const searchSamples = Math.round(
|
|
||||||
(config.maximumOutputLatencySeconds + config.chirp.durationSeconds + 0.05) * config.sampleRate
|
|
||||||
);
|
|
||||||
|
|
||||||
return devices.map((device, slot) => {
|
|
||||||
const envelope = EchoDsp.matchedFilterEnvelope(device.recording, template);
|
|
||||||
const detected = EchoDsp.detectSlotPeaks({
|
|
||||||
envelope,
|
|
||||||
slotCount: devices.length,
|
|
||||||
slotSamples,
|
|
||||||
ownSlot: slot,
|
|
||||||
ownSearchStart: device.ownSearchStart,
|
|
||||||
ownSearchSamples: searchSamples,
|
|
||||||
peakOptions: config.peakOptions ?? {}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId: `sim-${slot}`,
|
|
||||||
slot,
|
|
||||||
sampleRate: config.sampleRate,
|
|
||||||
epsilon: config.epsilon[slot],
|
|
||||||
peaks: (detected?.peaks ?? new Array(devices.length).fill(null)).map(peak => peak?.index ?? null)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Synthesize, detect and solve, reporting recovered geometry against the ground truth. */
|
|
||||||
runRound(overrides = {}) {
|
|
||||||
const config = this.buildConfiguration(overrides);
|
|
||||||
const round = this.synthesizeRound(config);
|
|
||||||
const reports = this.detectAll(round, config);
|
|
||||||
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
|
|
||||||
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
reports,
|
|
||||||
...solved,
|
|
||||||
distanceErrors: this.distanceErrors(solved.matrix, config),
|
|
||||||
positionErrors: this.positionErrors(solved, config)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceErrors(matrix, config) {
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++)
|
|
||||||
for (let j = i + 1; j < matrix.length; j++) {
|
|
||||||
const truth = this.separation(config.positions[i], config.positions[j]);
|
|
||||||
errors.push({
|
|
||||||
pair: [i, j],
|
|
||||||
truth,
|
|
||||||
measured: matrix[i][j],
|
|
||||||
error: matrix[i][j] === null ? null : matrix[i][j] - truth
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
|
|
||||||
positionErrors({ keep, points }, config) {
|
|
||||||
if (points.length !== keep.length || points.length < 2) return [];
|
|
||||||
|
|
||||||
const truth = keep.map(index => config.positions[index]);
|
|
||||||
const aligned = EchoDsp.alignToReference(points, truth);
|
|
||||||
return aligned.map((point, i) => this.separation(point, truth[i]));
|
|
||||||
},
|
|
||||||
|
|
||||||
worstDistanceError(result) {
|
|
||||||
const magnitudes = result.distanceErrors.map(({ error }) => (error === null ? Infinity : Math.abs(error)));
|
|
||||||
return magnitudes.length === 0 ? 0 : Math.max(...magnitudes);
|
|
||||||
},
|
|
||||||
|
|
||||||
worstPositionError(result) {
|
|
||||||
return result.positionErrors.length === 0 ? Infinity : Math.max(...result.positionErrors);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") window.EchoSim = EchoSim;
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/*
|
|
||||||
* Page wiring for /echo. Renders what the session measures: the matched-filter trace with the
|
|
||||||
* arrivals it picked, the pairwise ranges, and — while only two devices are present — one large
|
|
||||||
* number, because that number is the whole measurement.
|
|
||||||
*/
|
|
||||||
const EchoPage = {
|
|
||||||
HISTORY_LENGTH: 12,
|
|
||||||
|
|
||||||
elements: {},
|
|
||||||
lastSolved: null,
|
|
||||||
lastDiagnostics: null,
|
|
||||||
history: [],
|
|
||||||
roundsSeen: 0,
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this.elements = {
|
|
||||||
room: document.getElementById("echoRoom"),
|
|
||||||
name: document.getElementById("echoName"),
|
|
||||||
join: document.getElementById("echoJoin"),
|
|
||||||
leave: document.getElementById("echoLeave"),
|
|
||||||
status: document.getElementById("echoStatus"),
|
|
||||||
warnings: document.getElementById("echoWarnings"),
|
|
||||||
readout: document.getElementById("echoReadout"),
|
|
||||||
pairs: document.getElementById("echoPairs"),
|
|
||||||
roster: document.getElementById("echoRoster"),
|
|
||||||
trace: document.getElementById("echoTrace"),
|
|
||||||
epsilon: document.getElementById("echoEpsilon"),
|
|
||||||
shareLink: document.getElementById("echoShareLink"),
|
|
||||||
diagnostics: document.getElementById("echoDiagnostics")
|
|
||||||
};
|
|
||||||
|
|
||||||
this.elements.room.value = new URLSearchParams(location.search).get("room") ?? this.randomCode();
|
|
||||||
this.elements.epsilon.value = EchoSession.loadEpsilon();
|
|
||||||
this.elements.epsilon.addEventListener("change", () => this.applyEpsilon());
|
|
||||||
this.elements.join.addEventListener("click", () => this.join());
|
|
||||||
this.elements.leave.addEventListener("click", () => this.leave());
|
|
||||||
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
randomCode() {
|
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
return Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
|
|
||||||
},
|
|
||||||
|
|
||||||
applyEpsilon() {
|
|
||||||
const metres = Number(this.elements.epsilon.value);
|
|
||||||
if (!Number.isFinite(metres) || metres < 0) return null;
|
|
||||||
|
|
||||||
EchoSession.setEpsilon(metres);
|
|
||||||
return metres;
|
|
||||||
},
|
|
||||||
|
|
||||||
async join() {
|
|
||||||
const roomCode = this.elements.room.value.trim().toUpperCase();
|
|
||||||
if (!roomCode) return null;
|
|
||||||
|
|
||||||
this.setStatus("Requesting the microphone…");
|
|
||||||
this.elements.join.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const joined = await EchoSession.join(roomCode, this.deviceName(), { onUpdate: update => this.handle(update) });
|
|
||||||
this.showJoined(joined);
|
|
||||||
return joined;
|
|
||||||
} catch (error) {
|
|
||||||
this.setStatus(`Could not start: ${error.message}`);
|
|
||||||
this.elements.join.disabled = false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deviceName() {
|
|
||||||
const typed = this.elements.name.value.trim();
|
|
||||||
if (typed) return typed;
|
|
||||||
|
|
||||||
return /android|iphone|ipad|mobile/i.test(navigator.userAgent) ? "phone" : "laptop";
|
|
||||||
},
|
|
||||||
|
|
||||||
showJoined(joined) {
|
|
||||||
this.elements.leave.hidden = false;
|
|
||||||
this.elements.room.disabled = true;
|
|
||||||
this.elements.name.disabled = true;
|
|
||||||
this.elements.shareLink.textContent = `${location.origin}/echo?room=${joined.roomCode}`;
|
|
||||||
this.elements.shareLink.href = `/echo?room=${joined.roomCode}`;
|
|
||||||
this.renderWarnings(joined.audio);
|
|
||||||
this.renderRoster(joined.room.devices);
|
|
||||||
this.setStatus(`Listening at ${joined.audio.sampleRate}Hz. Open the same room on another device.`);
|
|
||||||
return joined;
|
|
||||||
},
|
|
||||||
|
|
||||||
async leave() {
|
|
||||||
await EchoSession.leave();
|
|
||||||
this.elements.leave.hidden = true;
|
|
||||||
this.elements.join.disabled = false;
|
|
||||||
this.elements.room.disabled = false;
|
|
||||||
this.elements.name.disabled = false;
|
|
||||||
this.setStatus("Left the room.");
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
handle(update) {
|
|
||||||
if (update.kind === "room") return this.renderRoster(update.room.devices);
|
|
||||||
if (update.kind === "envelope") {
|
|
||||||
this.renderDiagnostics(update.diagnostics);
|
|
||||||
return this.renderTrace(update);
|
|
||||||
}
|
|
||||||
if (update.kind === "solved") return this.renderSolved(update);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-slot residual is the diagnostic that matters: it is how far each chirp landed from where
|
|
||||||
* its slot said it would. Steady residuals mean the arrivals are being attributed correctly;
|
|
||||||
* residuals jumping by more than a slot mean they are not, and every range is then meaningless.
|
|
||||||
*/
|
|
||||||
renderDiagnostics(diagnostics) {
|
|
||||||
if (!diagnostics) return null;
|
|
||||||
this.lastDiagnostics = diagnostics;
|
|
||||||
|
|
||||||
const slots = diagnostics.slots
|
|
||||||
.map(slot => {
|
|
||||||
const label = slot.own ? "self" : slot.deviceId;
|
|
||||||
const residual = slot.residualMs === null ? "missed" : `${slot.residualMs > 0 ? "+" : ""}${slot.residualMs}ms`;
|
|
||||||
return `<li class="${slot.residualMs === null ? "echo-missed" : ""}">${label} · ${residual}${slot.snr === null ? "" : ` · snr ${slot.snr}`}</li>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
this.elements.diagnostics.innerHTML = `
|
|
||||||
<ul class="echo-slots">${slots}</ul>
|
|
||||||
<p class="echo-diag-line">
|
|
||||||
capture lag ${diagnostics.captureLagMs}ms · output latency ${diagnostics.outputLatencyMs}ms ·
|
|
||||||
clock offset ${diagnostics.serverOffsetMs}ms · rounds sat out ${diagnostics.skippedRounds} ·
|
|
||||||
input peak ${diagnostics.inputPeak}${diagnostics.clipping ? " <strong>CLIPPING</strong>" : ""}
|
|
||||||
</p>`;
|
|
||||||
|
|
||||||
return diagnostics;
|
|
||||||
},
|
|
||||||
|
|
||||||
recordHistory(metres) {
|
|
||||||
this.history.push(metres);
|
|
||||||
if (this.history.length > this.HISTORY_LENGTH) this.history.shift();
|
|
||||||
|
|
||||||
const spread = Math.max(...this.history) - Math.min(...this.history);
|
|
||||||
return { spread, count: this.history.length };
|
|
||||||
},
|
|
||||||
|
|
||||||
setStatus(message) {
|
|
||||||
this.elements.status.textContent = message;
|
|
||||||
return message;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderWarnings(audio) {
|
|
||||||
this.elements.warnings.innerHTML = "";
|
|
||||||
|
|
||||||
for (const warning of audio.warnings) {
|
|
||||||
const item = document.createElement("p");
|
|
||||||
item.className = "echo-warning";
|
|
||||||
item.textContent = warning;
|
|
||||||
this.elements.warnings.appendChild(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return audio.warnings.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderRoster(devices) {
|
|
||||||
this.elements.roster.innerHTML = "";
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
const row = document.createElement("li");
|
|
||||||
row.className = device.deviceId === EchoSession.deviceId ? "echo-device echo-device-self" : "echo-device";
|
|
||||||
row.textContent = `${device.displayName} · ${device.sampleRate}Hz`;
|
|
||||||
this.elements.roster.appendChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (devices.length < 2) this.setStatus("Waiting for a second device to join this room.");
|
|
||||||
return devices.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderSolved({ result, reports, solved }) {
|
|
||||||
this.roundsSeen++;
|
|
||||||
const raw = EchoDsp.buildDistanceMatrix(
|
|
||||||
reports.map(report => ({ ...report, epsilon: 0 })),
|
|
||||||
{ speedOfSound: EchoDsp.speedOfSound(EchoSession.TEMPERATURE_CELSIUS) }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.lastSolved = { result, reports, solved, raw };
|
|
||||||
this.renderReadout(reports, solved, raw);
|
|
||||||
this.renderPairs(reports, solved, raw);
|
|
||||||
this.setStatus(`Round ${this.roundsSeen} · ${reports.length} of ${EchoSession.devices.length} devices reported`);
|
|
||||||
return solved;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderReadout(reports, solved, raw) {
|
|
||||||
const readout = this.elements.readout;
|
|
||||||
|
|
||||||
if (reports.length !== 2 || solved.matrix[0][1] === null) {
|
|
||||||
readout.innerHTML = `<span class="echo-readout-idle">${reports.length < 2 ? "waiting for a pair" : "measuring…"}</span>`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const corrected = solved.matrix[0][1];
|
|
||||||
const { spread, count } = this.recordHistory(corrected);
|
|
||||||
readout.innerHTML = `
|
|
||||||
<span class="echo-metres">${corrected.toFixed(2)}<small>m</small></span>
|
|
||||||
<span class="echo-readout-detail">
|
|
||||||
raw ${raw[0][1].toFixed(3)}m · calibration +${(corrected - raw[0][1]).toFixed(3)}m ·
|
|
||||||
spread over last ${count} ${spread.toFixed(2)}m
|
|
||||||
</span>`;
|
|
||||||
|
|
||||||
return corrected;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderPairs(reports, solved, raw) {
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < reports.length; i++)
|
|
||||||
for (let j = i + 1; j < reports.length; j++) {
|
|
||||||
const dropped = !solved.keep.includes(i) || !solved.keep.includes(j);
|
|
||||||
const measured = solved.matrix[i][j];
|
|
||||||
rows.push(`<tr class="${dropped ? "echo-dropped" : ""}">
|
|
||||||
<td>${reports[i].deviceId} ↔ ${reports[j].deviceId}</td>
|
|
||||||
<td>${measured === null ? "—" : measured.toFixed(3) + " m"}</td>
|
|
||||||
<td>${raw[i][j] === null ? "—" : raw[i][j].toFixed(3) + " m"}</td>
|
|
||||||
</tr>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elements.pairs.innerHTML = rows.length
|
|
||||||
? `<table><thead><tr><th>pair</th><th>range</th><th>raw</th></tr></thead><tbody>${rows.join("")}</tbody></table>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return rows.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** The matched-filter trace, with a marker on each arrival the detector accepted. */
|
|
||||||
renderTrace({ envelope, detected, round }) {
|
|
||||||
const canvas = this.elements.trace;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
const width = (canvas.width = canvas.clientWidth);
|
|
||||||
const height = (canvas.height = 160);
|
|
||||||
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length).value || 1;
|
|
||||||
|
|
||||||
context.clearRect(0, 0, width, height);
|
|
||||||
context.strokeStyle = "rgba(9, 255, 0, 0.75)";
|
|
||||||
context.beginPath();
|
|
||||||
|
|
||||||
const bucket = envelope.length / width;
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const start = Math.floor(x * bucket);
|
|
||||||
const highest = EchoDsp.maxInRange(envelope, start, Math.min(envelope.length, Math.floor(start + bucket))).value;
|
|
||||||
const y = height - (highest / peak) * (height - 8) - 4;
|
|
||||||
x === 0 ? context.moveTo(x, y) : context.lineTo(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.stroke();
|
|
||||||
this.drawMarkers(context, detected, round, envelope.length, width, height);
|
|
||||||
return canvas;
|
|
||||||
},
|
|
||||||
|
|
||||||
drawMarkers(context, detected, round, envelopeLength, width, height) {
|
|
||||||
if (!detected) return null;
|
|
||||||
|
|
||||||
context.font = "11px 'Cascadia Code', monospace";
|
|
||||||
|
|
||||||
detected.peaks.forEach((peak, slot) => {
|
|
||||||
if (!peak) return;
|
|
||||||
|
|
||||||
const x = (peak.index / envelopeLength) * width;
|
|
||||||
const own = slot === round.ownSlot;
|
|
||||||
context.strokeStyle = own ? "#5fff5f" : "rgba(255, 255, 255, 0.55)";
|
|
||||||
context.fillStyle = context.strokeStyle;
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(x, 0);
|
|
||||||
context.lineTo(x, height);
|
|
||||||
context.stroke();
|
|
||||||
context.fillText(own ? `self (${slot})` : `slot ${slot}`, x + 4, 14 + slot * 13);
|
|
||||||
});
|
|
||||||
|
|
||||||
return detected.peaks.length;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => EchoPage.start());
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Particle Simulator - Modular Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The particle simulator has been refactored from a single 780-line monolithic file into a clean, modular architecture with separate ES6 modules for each concern.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
particles/
|
||||||
|
├── src/ Source modules (ES6)
|
||||||
|
│ ├── constants.js Physics & UI constants (40 lines)
|
||||||
|
│ ├── utils.js Utility functions (47 lines)
|
||||||
|
│ ├── random.js PRNG implementation (18 lines)
|
||||||
|
│ ├── colors.js Color palette configuration (16 lines)
|
||||||
|
│ ├── canvas.js Canvas utilities (37 lines)
|
||||||
|
│ ├── particles.js Particle state management (96 lines)
|
||||||
|
│ ├── spatial-grid.js Spatial grid optimization (76 lines)
|
||||||
|
│ ├── rules.js Interaction rules (71 lines)
|
||||||
|
│ ├── physics.js Physics simulation (193 lines)
|
||||||
|
│ ├── renderer.js Rendering logic (35 lines)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── toolbar.js Toolbar responsiveness (34 lines)
|
||||||
|
│ │ └── rule-editor.js Rule editor controls (72 lines)
|
||||||
|
│ └── main.js Application entry point (213 lines)
|
||||||
|
├── build.js Simple bundler script
|
||||||
|
├── page.bundle.js Built output (auto-generated)
|
||||||
|
└── README.md This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
- **constants.js** - All configurable physics parameters and constants
|
||||||
|
- **utils.js** - General-purpose utility functions (formatNumber, wrapPosition, etc.)
|
||||||
|
- **random.js** - Deterministic pseudo-random number generator (mulberry32)
|
||||||
|
- **colors.js** - Particle color palette and labels
|
||||||
|
|
||||||
|
### Simulation Modules
|
||||||
|
|
||||||
|
- **particles.js** - Particle state using Structure of Arrays (SoA) pattern
|
||||||
|
- **spatial-grid.js** - Spatial partitioning for O(1) neighbor queries
|
||||||
|
- **physics.js** - Force calculations, integration, and collision resolution
|
||||||
|
- **renderer.js** - Canvas rendering with motion trails
|
||||||
|
- **rules.js** - Interaction matrix generation and validation
|
||||||
|
|
||||||
|
### UI Modules
|
||||||
|
|
||||||
|
- **canvas.js** - Canvas initialization and DPI scaling
|
||||||
|
- **ui/toolbar.js** - Responsive toolbar (mobile FAB / desktop bar)
|
||||||
|
- **ui/rule-editor.js** - Interactive rule editing controls
|
||||||
|
- **main.js** - Application orchestration and event handling
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The project uses a simple custom bundler (`build.js`) that combines all ES6 modules into a single IIFE for browser compatibility.
|
||||||
|
|
||||||
|
### Automatic Build on Startup (Recommended)
|
||||||
|
|
||||||
|
The bundle is **automatically built when the application starts**. The `ParticleBundler` service in `Program.cs` runs the Node.js build script before `app.Run()`.
|
||||||
|
|
||||||
|
When you start the application with `dotnet run`, you'll see:
|
||||||
|
```
|
||||||
|
🔨 Building particle simulator bundle...
|
||||||
|
✓ Processing constants.js
|
||||||
|
✓ Processing utils.js
|
||||||
|
...
|
||||||
|
✨ Bundle created successfully!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build (Optional)
|
||||||
|
|
||||||
|
You can also manually rebuild the bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd JoshHeaps.Net/wwwroot/js/particles
|
||||||
|
node build.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build output:
|
||||||
|
- All modules are concatenated in dependency order
|
||||||
|
- Import/export statements are removed
|
||||||
|
- Code is wrapped in an IIFE (Immediately Invoked Function Expression)
|
||||||
|
- Result is ~38 KB of clean, minification-ready code
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Edit source modules** in `src/` directory
|
||||||
|
2. **Start the application**: `dotnet run` (bundle is built automatically)
|
||||||
|
3. **Test in browser** - The bundle is automatically used by the page
|
||||||
|
4. **Commit** source modules (bundle is auto-generated, but should be committed too)
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
### 🎯 Single Responsibility
|
||||||
|
Each module has one clear, focused purpose. No file exceeds 200 lines.
|
||||||
|
|
||||||
|
### 🧪 Testable
|
||||||
|
Individual modules can be tested in isolation.
|
||||||
|
|
||||||
|
### 📖 Readable
|
||||||
|
Easy to navigate and understand. New developers can quickly find what they need.
|
||||||
|
|
||||||
|
### ♻️ Reusable
|
||||||
|
Modules like `spatial-grid.js` or `random.js` can be reused in other projects.
|
||||||
|
|
||||||
|
### 🔍 Maintainable
|
||||||
|
Bug fixes and feature additions are localized to specific modules.
|
||||||
|
|
||||||
|
### 📦 Dependency Clarity
|
||||||
|
Import statements clearly show what each module depends on.
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
### Structure of Arrays (SoA)
|
||||||
|
The `particles.js` module uses separate typed arrays for each property (position, velocity, color) instead of an array of particle objects. This improves cache locality and performance.
|
||||||
|
|
||||||
|
### Encapsulation
|
||||||
|
Internal state is hidden. Modules expose only necessary functions through exports.
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
- Physics logic is completely separate from rendering
|
||||||
|
- UI controls are separate from simulation logic
|
||||||
|
- State management is centralized in `particles.js`
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
The `main.js` module wires everything together, passing dependencies explicitly.
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
This modular architecture maintains 100% compatibility with the original implementation:
|
||||||
|
- Same visual output
|
||||||
|
- Same physics behavior
|
||||||
|
- Same UI/UX
|
||||||
|
- Same performance characteristics
|
||||||
|
|
||||||
|
The ONLY difference is the code organization, which is dramatically improved.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple ES6 Module Bundler
|
||||||
|
* Bundles ES6 modules into a single IIFE for browser compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const srcDir = path.join(__dirname, 'src');
|
||||||
|
const outputFile = path.join(__dirname, 'page.bundle.js');
|
||||||
|
|
||||||
|
// Module dependency order (topological sort)
|
||||||
|
const moduleOrder = [
|
||||||
|
'constants.js',
|
||||||
|
'utils.js',
|
||||||
|
'random.js',
|
||||||
|
'colors.js',
|
||||||
|
'canvas.js',
|
||||||
|
'particles.js',
|
||||||
|
'spatial-grid.js',
|
||||||
|
'rules.js',
|
||||||
|
'physics.js',
|
||||||
|
'renderer.js',
|
||||||
|
'ui/toolbar.js',
|
||||||
|
'ui/rule-editor.js',
|
||||||
|
'main.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and process a module file
|
||||||
|
* @param {string} modulePath - Path to module file
|
||||||
|
* @returns {string} Processed module content
|
||||||
|
*/
|
||||||
|
function processModule(modulePath) {
|
||||||
|
const fullPath = path.join(srcDir, modulePath);
|
||||||
|
let content = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
|
||||||
|
// Remove export statements (we'll use direct assignment in IIFE)
|
||||||
|
content = content.replace(/export\s+(const|let|var|function|class)\s+/g, '$1 ');
|
||||||
|
content = content.replace(/export\s+\{[^}]+\};?/g, '');
|
||||||
|
content = content.replace(/export\s+default\s+/g, '');
|
||||||
|
|
||||||
|
// Remove import statements (modules are already in order)
|
||||||
|
content = content.replace(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
content = content.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
content = content.replace(/import\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle all modules into a single file
|
||||||
|
*/
|
||||||
|
function bundle() {
|
||||||
|
console.log('🔨 Building particle simulator bundle...');
|
||||||
|
|
||||||
|
let bundledContent = '(function () {\n "use strict";\n\n';
|
||||||
|
|
||||||
|
// Process each module in dependency order
|
||||||
|
for (const modulePath of moduleOrder) {
|
||||||
|
console.log(` ✓ Processing ${modulePath}`);
|
||||||
|
const moduleContent = processModule(modulePath);
|
||||||
|
bundledContent += ` // ========================================\n`;
|
||||||
|
bundledContent += ` // MODULE: ${modulePath}\n`;
|
||||||
|
bundledContent += ` // ========================================\n\n`;
|
||||||
|
|
||||||
|
// Indent module content
|
||||||
|
const indentedContent = moduleContent
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.length > 0 ? ' ' + line : line)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
bundledContent += indentedContent + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
bundledContent += '})();\n';
|
||||||
|
|
||||||
|
// Write bundle to output file
|
||||||
|
fs.writeFileSync(outputFile, bundledContent, 'utf8');
|
||||||
|
|
||||||
|
const stats = fs.statSync(outputFile);
|
||||||
|
const sizeKB = (stats.size / 1024).toFixed(2);
|
||||||
|
|
||||||
|
console.log(`✨ Bundle created successfully!`);
|
||||||
|
console.log(` Output: ${outputFile}`);
|
||||||
|
console.log(` Size: ${sizeKB} KB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bundler
|
||||||
|
try {
|
||||||
|
bundle();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Build failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Canvas Utilities
|
||||||
|
* Canvas setup and resizing functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare canvas context with proper DPI scaling
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @returns {CanvasRenderingContext2D}
|
||||||
|
*/
|
||||||
|
export function prepareCanvasContext(canvas) {
|
||||||
|
const context = canvas.getContext("2d", { alpha: false });
|
||||||
|
resizeCanvasToDisplaySize(canvas, context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize canvas to match display size with device pixel ratio scaling
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {CanvasRenderingContext2D} context
|
||||||
|
*/
|
||||||
|
export function resizeCanvasToDisplaySize(canvas, context) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale context so we can draw in CSS pixels
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user