Revert "Add db for backup and memory happiness"

This reverts commit 9003d2c4dd.
This commit is contained in:
jheaps
2025-08-04 14:20:01 -06:00
parent 9003d2c4dd
commit f4e377c116
27 changed files with 236 additions and 2673 deletions
@@ -1,8 +1,6 @@
using JoshHeaps.Net.DAL; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Utilities; using JoshHeaps.Net.Utilities;
using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
namespace JoshHeaps.Net.Tests.JsonTests; namespace JoshHeaps.Net.Tests.JsonTests;
@@ -13,7 +11,7 @@ internal class JsonConversionTests
public void ConvertBoard_WhenGivenStartingBoard_CanConvertToAndFromJson() public void ConvertBoard_WhenGivenStartingBoard_CanConvertToAndFromJson()
{ {
// Arrange // Arrange
var gameState = new ChessService(null).CreateNewGame(); var gameState = new ChessService().CreateNewGame();
// Act // Act
var serializedGameState = JsonSerializer.Serialize(gameState); var serializedGameState = JsonSerializer.Serialize(gameState);
+41 -88
View File
@@ -1,9 +1,6 @@
using JoshHeaps.Net.DAL; 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 JoshHeaps.Net.Utilities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -15,9 +12,7 @@ namespace JoshHeaps.Net.Controllers;
public class ChessController( public class ChessController(
IChessService chessService, IChessService chessService,
IHubContext<ChessHub> chessHub, IHubContext<ChessHub> chessHub,
BackgroundTaskQueue queue, IBackgroundTaskQueue queue) : ControllerBase
ChessDbAccess dbAccess,
StockfishManager stockfishManager) : ControllerBase
{ {
/// <summary> /// <summary>
/// Store of ongoing games. /// Store of ongoing games.
@@ -26,21 +21,13 @@ public class ChessController(
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = []; private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
private static ConcurrentDictionary<Guid, DateTimeOffset> _lastUpdated = [];
private static ConcurrentDictionary<Guid, GameState> _deliquents = [];
private static readonly Guid SystemId = Guid.NewGuid();
/// <summary> /// <summary>
/// Create a new chess game and store it in-memory. /// Create a new chess game and store it in-memory.
/// </summary> /// </summary>
[HttpGet("new")] [HttpGet("new")]
[HttpGet("new/{difficulty}")] [HttpGet("new/{difficulty}")]
public async Task<ActionResult> CreateGame(int difficulty = 20) public ActionResult CreateGame(int difficulty = 20)
{ {
await CheckForGameState();
var gameState = chessService.CreateNewGame(); var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; _games[gameState.GameId] = gameState;
@@ -50,29 +37,28 @@ public class ChessController(
gameState.ComputerDifficulty = difficulty; gameState.ComputerDifficulty = difficulty;
Guid playerId = Guid.NewGuid(); Guid playerId = Guid.NewGuid();
Guid computerId = Guid.NewGuid(); Guid computerId = Guid.NewGuid();
var isWhite = false; var isWhite = Random.Shared.Next(2) == 0;
gameState.Computer = new(difficulty);
if (isWhite) if (isWhite)
{ {
gameState.WhitePlayerId = playerId; gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId; gameState.BlackPlayerId = computerId;
gameState.ComputerColor = PieceColor.Black;
} }
else else
{ {
gameState.WhitePlayerId = computerId; gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId; gameState.BlackPlayerId = playerId;
gameState.ComputerColor = PieceColor.White;
queue.Queue(async () => queue.Queue(async () =>
{ {
// Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1)); await Task.Delay(TimeSpan.FromSeconds(1));
if (!await stockfishManager.Run(gameState)) await gameState.Computer.MakeMove(gameState, chessHub, chessService);
_deliquents[gameState.GameId] = gameState; });
}, gameState.GameId);
} }
await dbAccess.SaveAsync(gameState); ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
return Ok(new return Ok(new
{ {
@@ -88,10 +74,8 @@ public class ChessController(
/// and a bool indicating if they are White. /// and a bool indicating if they are White.
/// </summary> /// </summary>
[HttpGet("JoinGame")] [HttpGet("JoinGame")]
public async Task<ActionResult> JoinGame() public ActionResult JoinGame()
{ {
await CheckForGameState();
Console.WriteLine("joining game"); Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen); GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
@@ -116,8 +100,7 @@ public class ChessController(
isWhite = false; isWhite = false;
} }
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10)); ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
await dbAccess.SaveAsync(gameState);
return Ok(new return Ok(new
{ {
@@ -131,11 +114,9 @@ public class ChessController(
/// 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 async Task<ActionResult> GetGameState(Guid gameId) public ActionResult GetGameState(Guid gameId)
{ {
var gameState = await CheckForGameState(gameId); if (!_games.TryGetValue(gameId, out var gameState))
if (gameState is null)
return NotFound("Game not found"); return NotFound("Game not found");
var response = new var response = new
@@ -171,11 +152,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)
{ {
var gameState = await CheckForGameState(moveDto.GameId); if (!_games.TryGetValue(moveDto.GameId, out var gameState))
if (gameState is null)
return NotFound("Game not found"); return NotFound("Game not found");
// Check if player is authorized to move // Check if player is authorized to move
@@ -194,32 +173,28 @@ public class ChessController(
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black)) if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
return Forbid("You cannot move this piece."); return Forbid("You cannot move this piece.");
var result = await chessService.MakeMove(gameState, moveDto); var result = chessService.MakeMove(gameState, moveDto);
if (!result.Success) if (!result.Success)
return BadRequest(result); return BadRequest(result);
queue.Queue(async () =>
{
if (!await stockfishManager.Run(gameState))
_deliquents[gameState.GameId] = gameState;
}, gameState.GameId);
if (result.IsCheckmate || result.IsStalemate) if (result.IsCheckmate || result.IsStalemate)
{ {
// queue game removal // queue game removal
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10)); ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
} }
else else
{ {
// increase timeout if play continues. // increase timeout if play continues.
if (gameState.IsVsComputer) if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10)); ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
else else
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10)); ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
} }
if (gameState.IsVsComputer && gameState.Computer is not null)
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
return Ok(result); return Ok(result);
} }
@@ -227,15 +202,12 @@ public class ChessController(
/// Get the legal moves for a specific piece in a specific game. /// Get the legal moves for a specific piece in a specific game.
/// </summary> /// </summary>
[HttpGet("{gameId}/legalMoves/{pieceId}")] [HttpGet("{gameId}/legalMoves/{pieceId}")]
public async Task<ActionResult> GetLegalMoves(Guid gameId, string pieceId) public ActionResult GetLegalMoves(Guid gameId, string pieceId)
{ {
var gameState = await CheckForGameState(gameId); if (!_games.TryGetValue(gameId, out var gameState))
if (gameState is null)
return NotFound("Game not found"); return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId); var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
return Ok(moves); return Ok(moves);
} }
@@ -244,11 +216,9 @@ public class ChessController(
/// This was in your snippet, so we'll keep it. /// This was in your snippet, so we'll keep it.
/// </summary> /// </summary>
[HttpGet("{gameId}/legalMoves")] [HttpGet("{gameId}/legalMoves")]
public async Task<ActionResult> GetAllLegalMoves(Guid gameId) public ActionResult GetAllLegalMoves(Guid gameId)
{ {
var gameState = await CheckForGameState(gameId); if (!_games.TryGetValue(gameId, out var gameState))
if (gameState is null)
return NotFound("Game not found"); return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState) var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -258,53 +228,36 @@ public class ChessController(
Moves = x.moves Moves = x.moves
}); });
await stockfishManager.Run(gameState);
return Ok(allMoves); return Ok(allMoves);
} }
private void ScheduleRemoveGame(Guid id, TimeSpan delay) private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{ {
if (_gameRemovalTasks.ContainsKey(id)) if (_gameRemovalTasks.ContainsKey(id))
{ {
_lastUpdated[id] = DateTimeOffset.UtcNow; _gameRemovalTasks[id] = Task.Run(async () =>
{
await Task.Delay(delay);
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
});
return; return;
} }
_gameRemovalTasks.TryAdd(id, Task.Run(async () => _gameRemovalTasks.TryAdd(id, Task.Run(async () =>
{ {
_lastUpdated[id] = DateTimeOffset.UtcNow; await Task.Delay(delay);
while (_lastUpdated[id].Add(delay) >= DateTimeOffset.UtcNow) if (_games[id].Computer is not null)
await Task.Delay(TimeSpan.FromMinutes(10)); await _games[id].Computer!.DisposeAsync();
await GuidStore.RemoveAsync(id);
_games.Remove(id, out _); _games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _); _gameRemovalTasks.Remove(id, out _);
})); }));
} }
private async Task<GameState?> CheckForGameState(Guid gameId = default)
{
if (!_deliquents.IsEmpty)
queue.Queue(() => stockfishManager.RunDeliquents(_deliquents), SystemId);
if (_games.TryGetValue(gameId, out var existingGame))
return existingGame;
var gameState = await dbAccess.LoadAsync(gameId);
if (gameState is not null)
{
_games.TryAdd(gameState.GameId, gameState);
if (gameState.ComputerColor == gameState.CurrentPlayer)
_deliquents.TryAdd(gameState.GameId, gameState);
}
queue.Queue(() => stockfishManager.RunDeliquents(_deliquents), SystemId);
return gameState;
}
} }
+2 -6
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Services.Implementations; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Controllers; namespace JoshHeaps.Net.Controllers;
@@ -10,9 +9,6 @@ public class DebugController : ControllerBase
[HttpGet("IpCheck")] [HttpGet("IpCheck")]
public ActionResult<string> GetIpCheckingStatus() public ActionResult<string> GetIpCheckingStatus()
{ {
if (AutoIpUpdateService.IsEnabled) return Ok(Program.CheckingForIpUpdates.ToString());
return Ok("true");
return Ok("false");
} }
} }
+4 -61
View File
@@ -1,41 +1,28 @@
using JoshHeaps.Net.Models; using JoshHeaps.Net.Models;
using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
namespace JoshHeaps.Net.DAL; namespace JoshHeaps.Net.DAL;
public class ChessDbAccess(IDbContextFactory<ChessDbContext> factory) public class ChessDbAccess(ChessDbContext db)
{ {
private static readonly JsonSerializerOptions opts = private static readonly JsonSerializerOptions opts =
new(JsonSerializerDefaults.Web) { WriteIndented = false }; new(JsonSerializerDefaults.Web) { WriteIndented = false };
public async Task SaveAsync(GameState state, CancellationToken ct = default) public async Task SaveAsync(GameState state, CancellationToken ct = default)
{ {
await using var db = await factory.CreateDbContextAsync(ct);
var json = JsonSerializer.Serialize(state, opts); var json = JsonSerializer.Serialize(state, opts);
var entity = await db.Games.FindAsync([state.GameId], ct); var entity = await db.Games.FindAsync([state.GameId], ct)
?? new GameStateEntity { GameId = state.GameId };
if (entity is null)
{
entity = new GameStateEntity { GameId = state.GameId };
db.Games.Add(entity); // INSERT path
}
else
{
db.Games.Update(entity); // UPDATE path
}
entity.SerializedState = json; entity.SerializedState = json;
entity.LastMoveUtc = DateTime.UtcNow; entity.LastMoveUtc = DateTime.UtcNow;
db.Update(entity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public async Task<GameState?> LoadAsync(Guid id, CancellationToken ct = default) public async Task<GameState?> LoadAsync(Guid id, CancellationToken ct = default)
{ {
await using var db = await factory.CreateDbContextAsync(ct);
var e = await db.Games.FindAsync([id], ct); var e = await db.Games.FindAsync([id], ct);
return e is null return e is null
? null ? null
@@ -44,54 +31,10 @@ public class ChessDbAccess(IDbContextFactory<ChessDbContext> factory)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
await using var db = await factory.CreateDbContextAsync(ct);
if (await db.Games.FindAsync([id], ct) is { } e) if (await db.Games.FindAsync([id], ct) is { } e)
{ {
db.Remove(e); db.Remove(e);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public async Task DeleteAllAsync(Func<GameState, bool>? filter = null, CancellationToken ct = default)
{
await using var db = await factory.CreateDbContextAsync(ct);
filter ??= _ => true;
// 1. Pull every row (just once, not per request)
var entities = await db.Games
.AsNoTracking() // no change tracking needed
.Select(g => g.SerializedState)
.ToListAsync(ct);
List<GameState> allGames = entities.Select(json => JsonSerializer.Deserialize<GameState>(json, opts))
.OfType<GameState>()
.Where(filter)
.ToList();
foreach (var game in allGames)
{
await DeleteAsync(game.GameId, ct);
}
}
public async Task<List<GameState>> LoadAllAsync(Func<GameState, bool>? filter = null, CancellationToken ct = default)
{
await using var db = await factory.CreateDbContextAsync(ct);
filter ??= _ => true;
// 1. Pull every row (just once, not per request)
var entities = await db.Games
.AsNoTracking() // no change tracking needed
.Select(g => g.SerializedState)
.ToListAsync(ct);
List<GameState> allGames = entities.Select(json => JsonSerializer.Deserialize<GameState>(json, opts))
.OfType<GameState>()
.Where(filter)
.ToList();
return allGames;
}
} }
-9
View File
@@ -5,15 +5,6 @@ namespace JoshHeaps.Net.DAL;
public class ChessDbContext : DbContext public class ChessDbContext : DbContext
{ {
public ChessDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ChessDbContext>()
.UseSqlite("Data Source=Data/chess.db") // same conn-string as runtime
.Options;
return new ChessDbContext(options);
}
public ChessDbContext(DbContextOptions<ChessDbContext> opts) : base(opts) { } public ChessDbContext(DbContextOptions<ChessDbContext> opts) : base(opts) { }
public DbSet<GameStateEntity> Games => Set<GameStateEntity>(); public DbSet<GameStateEntity> Games => Set<GameStateEntity>();
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -30,7 +30,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,43 +0,0 @@
// <auto-generated />
using System;
using JoshHeaps.Net.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JoshHeaps.Net.Migrations
{
[DbContext(typeof(ChessDbContext))]
[Migration("20250705173523_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("JoshHeaps.Net.Models.GameStateEntity", b =>
{
b.Property<Guid>("GameId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("LastMoveUtc")
.HasColumnType("TEXT");
b.Property<string>("SerializedState")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("GameId");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,35 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JoshHeaps.Net.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Games",
columns: table => new
{
GameId = table.Column<Guid>(type: "TEXT", nullable: false),
SerializedState = table.Column<string>(type: "TEXT", nullable: false),
LastMoveUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Games", x => x.GameId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Games");
}
}
}
@@ -1,40 +0,0 @@
// <auto-generated />
using System;
using JoshHeaps.Net.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JoshHeaps.Net.Migrations
{
[DbContext(typeof(ChessDbContext))]
partial class ChessDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("JoshHeaps.Net.Models.GameStateEntity", b =>
{
b.Property<Guid>("GameId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("LastMoveUtc")
.HasColumnType("TEXT");
b.Property<string>("SerializedState")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("GameId");
b.ToTable("Games");
});
#pragma warning restore 612, 618
}
}
}
+4 -1
View File
@@ -47,9 +47,12 @@ 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 PieceColor ComputerColor { get; set; } = PieceColor.Black; // default to black for computer
public int ComputerDifficulty { get; set; } = 20; public int ComputerDifficulty { get; set; } = 20;
[JsonIgnore] // <-- exclude from JSON
[NotMapped] // <-- EF won't try to persist it either
public Stockfish? Computer { get; set; }
// optional: convenience // optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined; public bool IsOpen => !WhiteJoined || !BlackJoined;
+115 -13
View File
@@ -4,36 +4,40 @@ using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace JoshHeaps.Net;
// need to declare the class so I can add the public static bool
public class Program
{
public static bool CheckingForIpUpdates { get; private set; } = false;
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
var configuration = builder.Configuration; var configuration = builder.Configuration;
Task updateIpTask;
if (!builder.Environment.IsDevelopment())
updateIpTask = Run(configuration);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddSingleton<IChessService, ChessService>(); builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<BackgroundTaskQueue>(); builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.AddSingleton<ChessDbAccess>();
builder.Services.AddSingleton<StockfishManager>();
var cs = builder.Configuration.GetConnectionString("ChessDatabase"); var cs = builder.Configuration.GetConnectionString("ChessDatabase");
builder.Services.AddDbContext<ChessDbContext>(o => o.UseSqlite(cs));
builder.Services.AddPooledDbContextFactory<ChessDbContext>(o => o.UseSqlite(cs));
builder.Services.AddHostedService<GameCleanupService>();
if (!builder.Environment.IsDevelopment())
builder.Services.AddHostedService<AutoIpUpdateService>();
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<ChessDbContext>>(); var dbContext = scope.ServiceProvider.GetRequiredService<ChessDbContext>();
await using var db = await factory.CreateDbContextAsync(); dbContext.Database.Migrate();
db.Database.Migrate();
} }
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
@@ -67,3 +71,101 @@ app.MapControllers();
app.MapHub<ChessHub>("/chessHub"); app.MapHub<ChessHub>("/chessHub");
app.Run(); app.Run();
}
static async Task Run(IConfiguration config)
{
CheckingForIpUpdates = true;
HttpClient httpClient = new();
AAAARecord dnsRecord = await GetDnsRecordAsync(config);
string lastKnownIp = dnsRecord.content;
TimeSpan checkInterval = TimeSpan.FromMinutes(1);
while (true)
{
try
{
string currentIp = await GetPublicIpAsync(httpClient) ?? "";
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
await Task.Delay(checkInterval);
}
}
static async Task<string> GetPublicIpAsync(HttpClient client)
{
try
{
return await client.GetStringAsync(@"https://api.ipify.org/");
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to grab public ip");
return await GetPublicIpAsync(client);
}
}
static async Task<AAAARecord> GetDnsRecordAsync(IConfiguration config)
{
try
{
HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.result[0];
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to grab current ip");
return await GetDnsRecordAsync(config);
}
}
static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord 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
{
comment = "Update as needed",
content = ip,
name = "@",
proxied = true,
ttl = 3600,
type = "AAAA"
};
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.id}", content);
if (!result.IsSuccessStatusCode)
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to update ip");
await UpdateDnsIpAsync(config, record, ip);
}
}
record AAAARecord(string comment, string content, string name, string id);
record RecordList(List<AAAARecord> result);
}
@@ -1,105 +0,0 @@
namespace JoshHeaps.Net.Services.Implementations;
public class AutoIpUpdateService(
IConfiguration config,
ILogger<AutoIpUpdateService> log)
: BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
private static readonly HttpClient httpClient = new();
public static bool IsEnabled { get; private set; } = false;
protected override async Task ExecuteAsync(CancellationToken stop)
{
IsEnabled = true;
var timer = new PeriodicTimer(CheckInterval);
AAAARecord dnsRecord = await GetDnsRecordAsync();
string lastKnownIp = dnsRecord.Content;
while (await timer.WaitForNextTickAsync(stop))
{
try
{
string currentIp = await GetPublicIpAsync() ?? "";
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
}
}
catch (OperationCanceledException) { /* shutting down */ }
catch (Exception ex)
{
log.LogError(ex, "Error while attempting ip update");
}
}
}
private static async Task<string> GetPublicIpAsync()
{
try
{
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to grab public ip");
return await GetPublicIpAsync();
}
}
private async Task<AAAARecord> GetDnsRecordAsync()
{
try
{
HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.Result[0];
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to grab current ip");
return await GetDnsRecordAsync();
}
}
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord 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
{
comment = "Update as needed",
content = ip,
name = "@",
proxied = true,
ttl = 3600,
type = "AAAA"
};
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content);
if (!result.IsSuccessStatusCode)
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Reattempting to update ip");
await UpdateDnsIpAsync(config, record, ip);
}
}
record AAAARecord(string Comment, string Content, string Name, string Id);
record RecordList(List<AAAARecord> Result);
}
@@ -4,21 +4,18 @@ using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations; namespace JoshHeaps.Net.Services.Implementations;
public class BackgroundTaskQueue public class BackgroundTaskQueue : IBackgroundTaskQueue
{ {
private readonly ConcurrentDictionary<Guid, Task> _runningTasks = new(); private readonly ConcurrentDictionary<int, Task> _runningTasks = new();
public void Queue(Func<Task> workItem, Guid workId) public void Queue(Func<Task> workItem)
{ {
if (_runningTasks.ContainsKey(workId))
return;
var task = Task.Run(workItem); var task = Task.Run(workItem);
_runningTasks.TryAdd(workId, task); _runningTasks.TryAdd(task.Id, task);
task.ContinueWith(t => _runningTasks.TryRemove(workId, out _), TaskScheduler.Default); task.ContinueWith(t => _runningTasks.TryRemove(t.Id, out _), TaskScheduler.Default);
} }
public IReadOnlyDictionary<Guid, Task> Running => _runningTasks; public IReadOnlyCollection<Task> Running => [.. _runningTasks.Values];
public Task WhenAllDone() => Task.WhenAll(Running.Values); public Task WhenAllDone() => Task.WhenAll(Running);
} }
@@ -1,10 +1,9 @@
using JoshHeaps.Net.DAL; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations; namespace JoshHeaps.Net.Services.Implementations;
public class ChessService(ChessDbAccess dbAccess) : IChessService public class ChessService : IChessService
{ {
public GameState CreateNewGame() public GameState CreateNewGame()
{ {
@@ -123,7 +122,7 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
return legalMoves; return legalMoves;
} }
public async Task<MoveResultDto> MakeMove(GameState gameState, MoveDto moveDto) public MoveResultDto MakeMove(GameState gameState, MoveDto moveDto)
{ {
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId); var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
@@ -146,12 +145,6 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
gameState.MoveHistory.Add(notation); gameState.MoveHistory.Add(notation);
gameState.CurrentPlayer = gameState.CurrentPlayer == PieceColor.White
? PieceColor.Black
: PieceColor.White;
await dbAccess.SaveAsync(gameState);
return new MoveResultDto return new MoveResultDto
{ {
Success = true, Success = true,
@@ -173,8 +166,8 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
piece.Position = targetPos; piece.Position = targetPos;
gs.Board[targetPos.Row, targetPos.Col] = piece; gs.Board[targetPos.Row, targetPos.Col] = piece;
if (captured is not null && captured != piece) if (captured != null && captured != piece)
captured.Position = new Position(-1, -1); // Remove captured piece from board captured.Position = new Position(-1, -1);
bool wasFirstMove = !piece.HasMoved; bool wasFirstMove = !piece.HasMoved;
piece.HasMoved = true; piece.HasMoved = true;
@@ -186,6 +179,10 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
HandlePawnPromotionIfNeeded(piece, moveDto); HandlePawnPromotionIfNeeded(piece, moveDto);
UpdateCastlingRights(gs, piece, oldPos); UpdateCastlingRights(gs, piece, oldPos);
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
? PieceColor.Black
: PieceColor.White;
} }
private static void HandleEnPassantIfNeeded(GameState gs, ChessPiece piece, Position targetPos, ref ChessPiece? captured) private static void HandleEnPassantIfNeeded(GameState gs, ChessPiece piece, Position targetPos, ref ChessPiece? captured)
@@ -1,44 +0,0 @@
using JoshHeaps.Net.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace JoshHeaps.Net.Services.Implementations;
public sealed class GameCleanupService(
IDbContextFactory<ChessDbContext> dbFactory,
ILogger<GameCleanupService> log)
: BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stop)
{
var timer = new PeriodicTimer(CheckInterval);
while (await timer.WaitForNextTickAsync(stop))
{
try
{
await using var db = await dbFactory.CreateDbContextAsync(stop);
var now = DateTime.UtcNow;
var expired = await db.Games
.Where(g => g.LastMoveUtc.AddDays(7) < DateTime.UtcNow)
.ToListAsync(stop);
if (expired.Count == 0) continue;
db.Games.RemoveRange(expired);
await db.SaveChangesAsync(stop);
log.LogInformation("🗑️ Removed {Count} expired games", expired.Count);
}
catch (OperationCanceledException) { /* shutting down */ }
catch (Exception ex)
{
log.LogError(ex, "Error while purging old games");
}
}
}
}
@@ -1,9 +1,7 @@
using JoshHeaps.Net.DAL; using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
using JoshHeaps.Net.Utilities; using JoshHeaps.Net.Utilities;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -18,12 +16,6 @@ public sealed class Stockfish : IAsyncDisposable
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>(); private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill; private readonly int _skill;
private static readonly SemaphoreSlim _mutex = new(1, 1);
private static readonly List<Guid> _statesRunning = [];
public bool IsRunning => _p is not null && !_p.HasExited;
public bool InUse { get; set; }
public Stockfish(int skill = 20, int hash = 256) public Stockfish(int skill = 20, int hash = 256)
{ {
_skill = skill; _skill = skill;
@@ -52,7 +44,7 @@ public sealed class Stockfish : IAsyncDisposable
RedirectStandardInput = true, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true
} }
}; };
@@ -78,6 +70,7 @@ public sealed class Stockfish : IAsyncDisposable
Send("uci"); Send("uci");
WaitFor("uciok").GetAwaiter().GetResult(); WaitFor("uciok").GetAwaiter().GetResult();
Send($"setoption name Skill Level value {skill}");
Send($"setoption name Hash value {hash}"); Send($"setoption name Hash value {hash}");
Send("isready"); Send("isready");
WaitFor("readyok").GetAwaiter().GetResult(); WaitFor("readyok").GetAwaiter().GetResult();
@@ -116,15 +109,8 @@ public sealed class Stockfish : IAsyncDisposable
_p.Dispose(); _p.Dispose();
} }
public async Task<bool> MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService, ChessDbAccess dbAccess) public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
{ {
if (state.ComputerColor != state.CurrentPlayer)
return false;
Send($"setoption name Skill Level value {state.ComputerDifficulty}");
Send("isready");
await WaitFor("readyok");
var move = await GetBestMoveAsync(state.ToFen()); var move = await GetBestMoveAsync(state.ToFen());
var moveDto = move.ToMoveDto( var moveDto = move.ToMoveDto(
@@ -133,10 +119,8 @@ public sealed class Stockfish : IAsyncDisposable
? state.WhitePlayerId ? state.WhitePlayerId
: state.BlackPlayerId); : state.BlackPlayerId);
var result = await chessService.MakeMove(state, moveDto); var result = chessService.MakeMove(state, moveDto);
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result); await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
return true;
} }
} }
@@ -1,62 +0,0 @@
using JoshHeaps.Net.DAL;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Services.Implementations;
public class StockfishManager(IHubContext<ChessHub> chessHub, IChessService chessService, ChessDbAccess dbAccess)
{
private readonly List<Stockfish> _workers = [];
public async Task<bool> Run(GameState state)
{
if (!state.IsVsComputer)
return true;
if (!_workers.Any(x => x.InUse) && _workers.Count < 5)
_workers.Add(new Stockfish(state.ComputerDifficulty));
var worker = _workers.First(x => !x.InUse);
worker.InUse = true;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500));
return await worker.MakeMove(state, chessHub, chessService, dbAccess);
}
finally
{
worker.InUse = false;
}
}
public async Task<IEnumerable<KeyValuePair<Guid, GameState>>> RunDeliquents(ConcurrentDictionary<Guid, GameState> deliquents)
{
ConcurrentDictionary<Guid, GameState> continuedDeliquents = [];
List<Task> tasks = [];
foreach (var deliquent in deliquents)
{
tasks.Add(Task.Run(async () =>
{
var state = deliquent.Value;
bool result = false;
if (state.IsVsComputer && state.ComputerColor == state.CurrentPlayer)
result = !(await Run(state));
if (result)
continuedDeliquents[deliquent.Key] = deliquent.Value;
}));
}
await Task.WhenAll(tasks);
return continuedDeliquents;
}
}
@@ -2,5 +2,5 @@
public interface IBackgroundTaskQueue public interface IBackgroundTaskQueue
{ {
void Queue(Func<Task> workItem, Guid workId); void Queue(Func<Task> workItem);
} }
@@ -7,5 +7,5 @@ public interface IChessService
GameState CreateNewGame(); GameState CreateNewGame();
List<(ChessPiece piece, List<Position> moves)> GetAllLegalMoves(GameState gameState); List<(ChessPiece piece, List<Position> moves)> GetAllLegalMoves(GameState gameState);
List<Position> GetLegalMovesForPiece(GameState gameState, string pieceId); List<Position> GetLegalMovesForPiece(GameState gameState, string pieceId);
Task<MoveResultDto> MakeMove(GameState gameState, MoveDto moveDto); MoveResultDto MakeMove(GameState gameState, MoveDto moveDto);
} }
-57
View File
@@ -1,57 +0,0 @@
namespace JoshHeaps.Net.Utilities;
public static class GuidStore
{
private static readonly SemaphoreSlim _mutex = new(1, 1);
private static readonly List<Guid> _guids = [];
public static async Task AddAsync(Guid id)
{
await _mutex.WaitAsync();
try { _guids.Add(id); }
finally { _mutex.Release(); }
}
public static async Task<List<Guid>> TakeAllAsync()
{
await _mutex.WaitAsync();
try
{
var copy = _guids.ToList();
_guids.Clear();
return copy;
}
finally { _mutex.Release(); }
}
public static async Task<bool> ContainsAsync(Guid id)
{
await _mutex.WaitAsync();
try { return _guids.Contains(id); }
finally { _mutex.Release(); }
}
public static async Task RemoveAsync(Guid id)
{
await _mutex.WaitAsync();
try { _guids.Remove(id); }
finally { _mutex.Release(); }
}
public static async Task<bool> AddIfAvailable(Guid id)
{
await _mutex.WaitAsync();
try
{
if (!_guids.Contains(id))
{
_guids.Add(id);
return true; // Successfully added
}
return false; // Already exists
}
finally { _mutex.Release(); }
}
}
-2006
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
{
"dependencies": {
"@microsoft/signalr": "^8.0.7"
},
"devDependencies": {
"eslint": "^9.30.1"
}
}
-1
View File
@@ -11,7 +11,6 @@
.chessSquare { .chessSquare {
background-color: inherit; background-color: inherit;
position: relative; position: relative;
aspect-ratio: 1 / 1;
} }
.chessSquare.light { .chessSquare.light {
@@ -105,8 +105,8 @@ function renderPieces(pieces) {
} }
e.dataTransfer.setData("text/plain", JSON.stringify({ e.dataTransfer.setData("text/plain", JSON.stringify({
srcRow: piece.row, srcRow: piece.Row,
srcCol: piece.col srcCol: piece.Col
})) }))
} }
@@ -319,7 +319,7 @@ function getCookie(name) {
async function setupSignalRConnection() { async function setupSignalRConnection() {
signalRConnection = new signalR.HubConnectionBuilder() signalRConnection = new signalR.HubConnectionBuilder()
.withUrl("/chessHub") .withUrl("/chessHub")
.configureLogging(signalR.LogLevel.Trace) .configureLogging(signalR.LogLevel.Information)
.build(); .build();
signalRConnection.onclose(err => { signalRConnection.onclose(err => {
@@ -386,7 +386,7 @@ function updatePromotionModalImages(color) {
console.log("chessLogic.js loaded"); console.log("chessLogic.js loaded");
window.startNewGame = startNewGame; window.startNewGame = startNewGame;
document.addEventListener('DOMContentLoaded', async () => { window.addEventListener('load', async () => {
const savedGameId = getCookie("chessGameId"); const savedGameId = getCookie("chessGameId");
const savedPlayerId = getCookie("chessPlayerId"); const savedPlayerId = getCookie("chessPlayerId");
const savedPlayerIsWhite = getCookie("chessPlayerIsWhite"); const savedPlayerIsWhite = getCookie("chessPlayerIsWhite");