diff --git a/JoshHeaps.Net.UiTests/ApiTests.cs b/JoshHeaps.Net.UiTests/ApiTests.cs index 1889d2d..0b2bc37 100644 --- a/JoshHeaps.Net.UiTests/ApiTests.cs +++ b/JoshHeaps.Net.UiTests/ApiTests.cs @@ -11,6 +11,19 @@ public class ApiTests : PageTest private IAPIRequestContext? _apiContext; private TestConfiguration Config => TestConfiguration.Instance; + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!Config.Playwright.Headless) + { + Environment.SetEnvironmentVariable("HEADED", "1"); + } + if (Config.Playwright.SlowMotion > 0) + { + Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString()); + } + } + [SetUp] public async Task Setup() { diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 157cdd7..87a2042 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -22,7 +22,7 @@ public class ChessController( private static readonly ConcurrentDictionary _gameRemovalCancellationTokens = []; private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); - private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); /// @@ -165,7 +165,7 @@ public class ChessController( var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId; if (moveDto.PlayerId != expectedPlayerId) - return Forbid("You are not the current player."); + return StatusCode(403, "You are not the current player."); // Make sure player owns the piece var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId); @@ -174,7 +174,7 @@ public class ChessController( return NotFound("Chess piece Id does not exist"); if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black)) - return Forbid("You cannot move this piece."); + return StatusCode(403, "You cannot move this piece."); var result = chessService.MakeMove(gameState, moveDto); diff --git a/JoshHeaps.Net/JoshHeaps.Net.csproj b/JoshHeaps.Net/JoshHeaps.Net.csproj index 52e2186..51f7fd2 100644 --- a/JoshHeaps.Net/JoshHeaps.Net.csproj +++ b/JoshHeaps.Net/JoshHeaps.Net.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + 53ed685c-bdff-4306-8cc2-9fbe55c85713 diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml index b48b96e..1229f45 100644 --- a/JoshHeaps.Net/Pages/Index.cshtml +++ b/JoshHeaps.Net/Pages/Index.cshtml @@ -16,14 +16,14 @@

Chess

- As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future. + 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 :)

diff --git a/JoshHeaps.Net/Services/Implementations/AutoIpUpdateService.cs b/JoshHeaps.Net/Services/Implementations/AutoIpUpdateService.cs index 81b2c8d..7630aed 100644 --- a/JoshHeaps.Net/Services/Implementations/AutoIpUpdateService.cs +++ b/JoshHeaps.Net/Services/Implementations/AutoIpUpdateService.cs @@ -8,31 +8,23 @@ public class AutoIpUpdateService( ILogger log) : BackgroundService { - private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5); - private static readonly HttpClient httpClient = new(); public static bool IsEnabled { get; private set; } = false; + private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); + private static readonly HttpClient _httpClient = new(); + protected override async Task ExecuteAsync(CancellationToken stop) { IsEnabled = true; - var timer = new PeriodicTimer(CheckInterval); - AAAARecord dnsRecord = await GetDnsRecordAsync(); - string lastKnownIp = dnsRecord.Content; + var timer = new PeriodicTimer(_checkInterval); while (await timer.WaitForNextTickAsync(stop)) { try { - string currentIp = await GetPublicIpAsync() ?? ""; - - if (lastKnownIp != currentIp) - { - await UpdateDnsIpAsync(config, dnsRecord, currentIp); - - lastKnownIp = currentIp; - } + await UpdateIpAddressIfChanged(); } - catch (OperationCanceledException) { /* shutting down */ } + catch (OperationCanceledException) { break; } catch (Exception ex) { log.LogError(ex, "Error while attempting ip update"); @@ -40,11 +32,33 @@ public class AutoIpUpdateService( } } + private async Task UpdateIpAddressIfChanged() + { + var dnsRecords = await GetDnsRecordAsync(); + string dnsRecordIp = dnsRecords[0].Content; + + if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content)) + dnsRecordIp = string.Empty; + + string publicIp = await GetPublicIpAsync() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp) + return; + + foreach (var dnsRecord in dnsRecords.Records) + { + if (dnsRecord.Content == publicIp) + continue; + + await UpdateDnsIpAsync(config, dnsRecord, publicIp); + } + } + private static async Task GetPublicIpAsync() { try { - return await httpClient.GetStringAsync(@"https://api.ipify.org/"); + return await _httpClient.GetStringAsync(@"https://api.ipify.org/"); } catch { @@ -55,7 +69,7 @@ public class AutoIpUpdateService( } } - private async Task GetDnsRecordAsync() + private async Task GetDnsRecordAsync() { try { @@ -64,8 +78,8 @@ public class AutoIpUpdateService( cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records"); Console.WriteLine(await result.Content.ReadAsStringAsync()); - var records = System.Text.Json.JsonSerializer.Deserialize(await result.Content.ReadAsStringAsync()); - return records!.Result[0]; + var records = JsonSerializer.Deserialize(await result.Content.ReadAsStringAsync()); + return records!; } catch { @@ -76,22 +90,23 @@ public class AutoIpUpdateService( } } - private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip) + private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip) { HttpClient cfClient = new(); cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); - object content = new + + object updateRequestBody = new { - comment = "Update as needed", + name = record.Name, + ttl = record.Ttl, + type = record.Type, + comment = record.Comment, content = ip, - name = "@", - proxied = true, - ttl = 3600, - type = "AAAA" + proxied = record.Proxied, }; - var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content); + var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody); if (!result.IsSuccessStatusCode) { @@ -102,11 +117,23 @@ public class AutoIpUpdateService( } } - record AAAARecord( + record DnsRecord( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("ttl")] int Ttl, + [property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("comment")] string Comment, [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("proxied")] bool Proxied, [property: JsonPropertyName("id")] string Id); - record RecordList([property: JsonPropertyName("result")] List Result); + record RecordList([property: JsonPropertyName("result")] List Records) + { + public DnsRecord this[int index] + { + get + { + return Records[index]; + } + } + } } diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index 9b76ef1..adcaf82 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -110,7 +110,6 @@ public class ChessService : IChessService var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId); if (piece == null) return []; - if (piece.Color != gameState.CurrentPlayer) return []; var candidateMoves = GenerateCandidateMoves(gameState, piece); var legalMoves = new List(); @@ -529,7 +528,14 @@ public class ChessService : IChessService var ep = gs.EnPassantTarget.Value; if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1) - moves.Add(ep); + { + // Verify there's an enemy pawn to capture + int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1; + var enemyPawn = gs.Board[enemyPawnRow, ep.Col]; + + if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color) + moves.Add(ep); + } } return moves; @@ -542,11 +548,7 @@ public class ChessService : IChessService => GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]); private static List GenerateQueenMoves(GameState gs, ChessPiece piece) - => GenerateSlidingMoves(gs, piece, - [ - (1, 0), (-1, 0), (0, 1), (0, -1), - (1, 1), (1, -1), (-1, 1), (-1, -1) - ]); + => [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)]; private static List GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions) { diff --git a/JoshHeaps.Net/wwwroot/css/chess/game.css b/JoshHeaps.Net/wwwroot/css/chess/game.css index 203a487..7ca7512 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/game.css +++ b/JoshHeaps.Net/wwwroot/css/chess/game.css @@ -1,5 +1,10 @@ -#chessBoard { +#boardContainer { + position: relative; + width: fit-content; margin: 2vw auto; +} + +#chessBoard { width: 60vw; height: 60vw; display: grid; @@ -158,4 +163,30 @@ width: 50px; height: 50px; pointer-events: none; /* ensures img doesn't steal the click */ +} + +.chessSquare .coordinate-label { + position: absolute; + font-size: 1.2vw; + font-weight: bold; + pointer-events: none; + user-select: none; +} + +.chessSquare.light .coordinate-label { + color: #656770; +} + +.chessSquare.dark .coordinate-label { + color: #ccc; +} + +.chessSquare .row-label { + top: 2px; + left: 4px; +} + +.chessSquare .col-label { + bottom: 2px; + right: 4px; } \ No newline at end of file diff --git a/JoshHeaps.Net/wwwroot/images/Chess.jpg b/JoshHeaps.Net/wwwroot/images/Chess.jpg index 18d4e6b..7eb03dc 100644 Binary files a/JoshHeaps.Net/wwwroot/images/Chess.jpg and b/JoshHeaps.Net/wwwroot/images/Chess.jpg differ diff --git a/JoshHeaps.Net/wwwroot/images/CompilerDemo.png b/JoshHeaps.Net/wwwroot/images/CompilerDemo.png index 94125c0..42b514c 100644 Binary files a/JoshHeaps.Net/wwwroot/images/CompilerDemo.png and b/JoshHeaps.Net/wwwroot/images/CompilerDemo.png differ diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 1d58820..19de069 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -27,9 +27,14 @@ const ChessAPI = { body: JSON.stringify(moveDto) }); + if (!response.ok) { + let message = await response.text(); + throw new Error(message || "Invalid move or not your turn."); + } + const result = await response.json(); - if (!response.ok || !result.success) { + if (!result.success) { throw new Error(result.message || "Invalid move or not your turn."); } diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js index 3f4ab77..0c5a59e 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js @@ -1,6 +1,7 @@ const ChessBoard = { renderPieces(pieces) { this.clearAllSquares(); + this.renderCoordinateLabels(); this.placePieces(pieces); this.setupSquareEventHandlers(); this.highlightPreviousMove(); @@ -106,6 +107,39 @@ const ChessBoard = { ChessAPI.handleMove(targetRow, targetCol); }; } + }, + + renderCoordinateLabels() { + const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + const ranks = ['8', '7', '6', '5', '4', '3', '2', '1']; + + // If player is black, reverse the coordinates + if (!GameState.currentPlayerIsWhite) { + files.reverse(); + ranks.reverse(); + } + + for (let i = 0; i < 64; i++) { + const square = document.getElementById(`square-${i}`); + const row = Math.floor(i / 8); + const col = i % 8; + + // Add rank label (1-8) on the leftmost column + if (col === 0) { + const rankLabel = document.createElement('span'); + rankLabel.className = 'coordinate-label row-label'; + rankLabel.textContent = ranks[row]; + square.appendChild(rankLabel); + } + + // Add file label (a-h) on the bottom row + if (row === 7) { + const fileLabel = document.createElement('span'); + fileLabel.className = 'coordinate-label col-label'; + fileLabel.textContent = files[col]; + square.appendChild(fileLabel); + } + } } }; diff --git a/JoshHeaps.Net/wwwroot/js/site.js b/JoshHeaps.Net/wwwroot/js/site.js index 398b0be..9ca0115 100644 --- a/JoshHeaps.Net/wwwroot/js/site.js +++ b/JoshHeaps.Net/wwwroot/js/site.js @@ -3,5 +3,5 @@ element.style.display = "none"; // Trigger reflow element.offsetHeight; // Force reflow element.style.display = ""; // Restore the original display - typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid````````````````Just````uhhhh`````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?"); + typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid...```````````````````Just...```````uhhhh...````````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?"); } \ No newline at end of file diff --git a/JoshHeaps.Net/wwwroot/js/utils.js b/JoshHeaps.Net/wwwroot/js/utils.js index fb14302..2479ff1 100644 --- a/JoshHeaps.Net/wwwroot/js/utils.js +++ b/JoshHeaps.Net/wwwroot/js/utils.js @@ -12,18 +12,19 @@ let intervalId; let index = 0; const punctuation = ['.', '!', '?']; +const punctuationDict = { + '.': 150, + '!': 200, + '?': 200, + ',': 100, + '`': 25, +}; let currentText = ""; function changeIntervalTime(element, text) { - if (punctuation.includes(text.charAt(index))) { - clearInterval(intervalId); - simulateTyping(element, text, 200); - } - else { - clearInterval(intervalId); - simulateTyping(element, text); - } + clearInterval(intervalId); + simulateTyping(element, text, punctuationDict[text.charAt(index)] ?? 50); } function simulateTyping(element, text, speed = 50) {