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.Utilities;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace JoshHeaps.Net.Tests.JsonTests;
@@ -13,7 +11,7 @@ internal class JsonConversionTests
public void ConvertBoard_WhenGivenStartingBoard_CanConvertToAndFromJson()
{
// Arrange
var gameState = new ChessService(null).CreateNewGame();
var gameState = new ChessService().CreateNewGame();
// Act
var serializedGameState = JsonSerializer.Serialize(gameState);
+42 -89
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.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using JoshHeaps.Net.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
@@ -15,9 +12,7 @@ namespace JoshHeaps.Net.Controllers;
public class ChessController(
IChessService chessService,
IHubContext<ChessHub> chessHub,
BackgroundTaskQueue queue,
ChessDbAccess dbAccess,
StockfishManager stockfishManager) : ControllerBase
IBackgroundTaskQueue queue) : ControllerBase
{
/// <summary>
/// Store of ongoing games.
@@ -26,21 +21,13 @@ public class ChessController(
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>
/// Create a new chess game and store it in-memory.
/// </summary>
[HttpGet("new")]
[HttpGet("new/{difficulty}")]
public async Task<ActionResult> CreateGame(int difficulty = 20)
public ActionResult CreateGame(int difficulty = 20)
{
await CheckForGameState();
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
@@ -50,29 +37,28 @@ public class ChessController(
gameState.ComputerDifficulty = difficulty;
Guid playerId = Guid.NewGuid();
Guid computerId = Guid.NewGuid();
var isWhite = false;
var isWhite = Random.Shared.Next(2) == 0;
gameState.Computer = new(difficulty);
if (isWhite)
{
gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId;
gameState.ComputerColor = PieceColor.Black;
}
else
{
gameState.WhitePlayerId = computerId;
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));
if (!await stockfishManager.Run(gameState))
_deliquents[gameState.GameId] = gameState;
}, gameState.GameId);
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
});
}
await dbAccess.SaveAsync(gameState);
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
return Ok(new
{
@@ -88,10 +74,8 @@ public class ChessController(
/// and a bool indicating if they are White.
/// </summary>
[HttpGet("JoinGame")]
public async Task<ActionResult> JoinGame()
public ActionResult JoinGame()
{
await CheckForGameState();
Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
@@ -116,8 +100,7 @@ public class ChessController(
isWhite = false;
}
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10));
await dbAccess.SaveAsync(gameState);
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
return Ok(new
{
@@ -131,11 +114,9 @@ public class ChessController(
/// Get the state of an existing game by ID.
/// </summary>
[HttpGet("{gameId}")]
public async Task<ActionResult> GetGameState(Guid gameId)
public ActionResult GetGameState(Guid gameId)
{
var gameState = await CheckForGameState(gameId);
if (gameState is null)
if (!_games.TryGetValue(gameId, out var gameState))
return NotFound("Game not found");
var response = new
@@ -171,11 +152,9 @@ public class ChessController(
/// The test passes a JSON body with a MoveDto.
/// </summary>
[HttpPost("move")]
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
public ActionResult MakeMove([FromBody] MoveDto moveDto)
{
var gameState = await CheckForGameState(moveDto.GameId);
if (gameState is null)
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
return NotFound("Game not found");
// 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))
return Forbid("You cannot move this piece.");
var result = await chessService.MakeMove(gameState, moveDto);
var result = chessService.MakeMove(gameState, moveDto);
if (!result.Success)
return BadRequest(result);
queue.Queue(async () =>
{
if (!await stockfishManager.Run(gameState))
_deliquents[gameState.GameId] = gameState;
}, gameState.GameId);
if (result.IsCheckmate || result.IsStalemate)
{
// queue game removal
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10));
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
}
else
{
// increase timeout if play continues.
if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(10));
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
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);
}
@@ -227,15 +202,12 @@ public class ChessController(
/// Get the legal moves for a specific piece in a specific game.
/// </summary>
[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 (gameState is null)
if (!_games.TryGetValue(gameId, out var gameState))
return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
return Ok(moves);
}
@@ -244,11 +216,9 @@ public class ChessController(
/// This was in your snippet, so we'll keep it.
/// </summary>
[HttpGet("{gameId}/legalMoves")]
public async Task<ActionResult> GetAllLegalMoves(Guid gameId)
public ActionResult GetAllLegalMoves(Guid gameId)
{
var gameState = await CheckForGameState(gameId);
if (gameState is null)
if (!_games.TryGetValue(gameId, out var gameState))
return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -258,53 +228,36 @@ public class ChessController(
Moves = x.moves
});
await stockfishManager.Run(gameState);
return Ok(allMoves);
}
private void ScheduleRemoveGame(Guid id, TimeSpan delay)
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{
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;
}
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
{
_lastUpdated[id] = DateTimeOffset.UtcNow;
await Task.Delay(delay);
while (_lastUpdated[id].Add(delay) >= DateTimeOffset.UtcNow)
await Task.Delay(TimeSpan.FromMinutes(10));
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
await GuidStore.RemoveAsync(id);
_games.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;
@@ -10,9 +9,6 @@ public class DebugController : ControllerBase
[HttpGet("IpCheck")]
public ActionResult<string> GetIpCheckingStatus()
{
if (AutoIpUpdateService.IsEnabled)
return Ok("true");
return Ok("false");
return Ok(Program.CheckingForIpUpdates.ToString());
}
}
+4 -61
View File
@@ -1,41 +1,28 @@
using JoshHeaps.Net.Models;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace JoshHeaps.Net.DAL;
public class ChessDbAccess(IDbContextFactory<ChessDbContext> factory)
public class ChessDbAccess(ChessDbContext db)
{
private static readonly JsonSerializerOptions opts =
new(JsonSerializerDefaults.Web) { WriteIndented = false };
public async Task SaveAsync(GameState state, CancellationToken ct = default)
{
await using var db = await factory.CreateDbContextAsync(ct);
var json = JsonSerializer.Serialize(state, opts);
var entity = await db.Games.FindAsync([state.GameId], ct);
if (entity is null)
{
entity = new GameStateEntity { GameId = state.GameId };
db.Games.Add(entity); // INSERT path
}
else
{
db.Games.Update(entity); // UPDATE path
}
var entity = await db.Games.FindAsync([state.GameId], ct)
?? new GameStateEntity { GameId = state.GameId };
entity.SerializedState = json;
entity.LastMoveUtc = DateTime.UtcNow;
db.Update(entity);
await db.SaveChangesAsync(ct);
}
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);
return e is null
? null
@@ -44,54 +31,10 @@ public class ChessDbAccess(IDbContextFactory<ChessDbContext> factory)
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)
{
db.Remove(e);
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 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 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>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.6" />
</ItemGroup>
</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 bool IsVsComputer { get; set; } = false;
public PieceColor ComputerColor { get; set; } = PieceColor.Black; // default to black for computer
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
public bool IsOpen => !WhiteJoined || !BlackJoined;
+154 -52
View File
@@ -4,66 +4,168 @@ using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
namespace JoshHeaps.Net;
// Add services to the container.
builder.Services.AddRazorPages();
var configuration = builder.Configuration;
builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<BackgroundTaskQueue>();
builder.Services.AddSingleton<ChessDbAccess>();
builder.Services.AddSingleton<StockfishManager>();
var cs = builder.Configuration.GetConnectionString("ChessDatabase");
builder.Services.AddPooledDbContextFactory<ChessDbContext>(o => o.UseSqlite(cs));
builder.Services.AddHostedService<GameCleanupService>();
if (!builder.Environment.IsDevelopment())
builder.Services.AddHostedService<AutoIpUpdateService>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
// need to declare the class so I can add the public static bool
public class Program
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<ChessDbContext>>();
await using var db = await factory.CreateDbContextAsync();
db.Database.Migrate();
}
public static bool CheckingForIpUpdates { get; private set; } = false;
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
public static void Main(string[] args)
{
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
ctx.Context.Response.Headers.Append("Pragma", "no-cache");
ctx.Context.Response.Headers.Append("Expires", "0");
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
var configuration = builder.Configuration;
Task updateIpTask;
if (!builder.Environment.IsDevelopment())
updateIpTask = Run(configuration);
builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
var cs = builder.Configuration.GetConnectionString("ChessDatabase");
builder.Services.AddDbContext<ChessDbContext>(o => o.UseSqlite(cs));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ChessDbContext>();
dbContext.Database.Migrate();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
ctx.Context.Response.Headers.Append("Pragma", "no-cache");
ctx.Context.Response.Headers.Append("Expires", "0");
}
});
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapHub<ChessHub>("/chessHub");
app.Run();
}
});
app.UseRouting();
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);
app.UseAuthorization();
while (true)
{
try
{
string currentIp = await GetPublicIpAsync(httpClient) ?? "";
app.MapRazorPages();
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
app.MapControllers();
lastKnownIp = currentIp;
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
app.MapHub<ChessHub>("/chessHub");
await Task.Delay(checkInterval);
}
}
app.Run();
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;
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);
_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 Task WhenAllDone() => Task.WhenAll(Running.Values);
public IReadOnlyCollection<Task> Running => [.. _runningTasks.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;
namespace JoshHeaps.Net.Services.Implementations;
public class ChessService(ChessDbAccess dbAccess) : IChessService
public class ChessService : IChessService
{
public GameState CreateNewGame()
{
@@ -123,7 +122,7 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
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);
@@ -146,12 +145,6 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
gameState.MoveHistory.Add(notation);
gameState.CurrentPlayer = gameState.CurrentPlayer == PieceColor.White
? PieceColor.Black
: PieceColor.White;
await dbAccess.SaveAsync(gameState);
return new MoveResultDto
{
Success = true,
@@ -173,8 +166,8 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
piece.Position = targetPos;
gs.Board[targetPos.Row, targetPos.Col] = piece;
if (captured is not null && captured != piece)
captured.Position = new Position(-1, -1); // Remove captured piece from board
if (captured != null && captured != piece)
captured.Position = new Position(-1, -1);
bool wasFirstMove = !piece.HasMoved;
piece.HasMoved = true;
@@ -186,6 +179,10 @@ public class ChessService(ChessDbAccess dbAccess) : IChessService
HandlePawnPromotionIfNeeded(piece, moveDto);
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)
@@ -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.Services.Interfaces;
using JoshHeaps.Net.Utilities;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.SignalR;
using System.Diagnostics;
using System.Runtime.InteropServices;
@@ -18,12 +16,6 @@ public sealed class Stockfish : IAsyncDisposable
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
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)
{
_skill = skill;
@@ -52,7 +44,7 @@ public sealed class Stockfish : IAsyncDisposable
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
CreateNoWindow = true
}
};
@@ -78,6 +70,7 @@ public sealed class Stockfish : IAsyncDisposable
Send("uci");
WaitFor("uciok").GetAwaiter().GetResult();
Send($"setoption name Skill Level value {skill}");
Send($"setoption name Hash value {hash}");
Send("isready");
WaitFor("readyok").GetAwaiter().GetResult();
@@ -116,15 +109,8 @@ public sealed class Stockfish : IAsyncDisposable
_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 moveDto = move.ToMoveDto(
@@ -133,10 +119,8 @@ public sealed class Stockfish : IAsyncDisposable
? state.WhitePlayerId
: 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);
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
{
void Queue(Func<Task> workItem, Guid workId);
void Queue(Func<Task> workItem);
}
@@ -7,5 +7,5 @@ public interface IChessService
GameState CreateNewGame();
List<(ChessPiece piece, List<Position> moves)> GetAllLegalMoves(GameState gameState);
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 {
background-color: inherit;
position: relative;
aspect-ratio: 1 / 1;
}
.chessSquare.light {
@@ -105,8 +105,8 @@ function renderPieces(pieces) {
}
e.dataTransfer.setData("text/plain", JSON.stringify({
srcRow: piece.row,
srcCol: piece.col
srcRow: piece.Row,
srcCol: piece.Col
}))
}
@@ -319,7 +319,7 @@ function getCookie(name) {
async function setupSignalRConnection() {
signalRConnection = new signalR.HubConnectionBuilder()
.withUrl("/chessHub")
.configureLogging(signalR.LogLevel.Trace)
.configureLogging(signalR.LogLevel.Information)
.build();
signalRConnection.onclose(err => {
@@ -386,7 +386,7 @@ function updatePromotionModalImages(color) {
console.log("chessLogic.js loaded");
window.startNewGame = startNewGame;
document.addEventListener('DOMContentLoaded', async () => {
window.addEventListener('load', async () => {
const savedGameId = getCookie("chessGameId");
const savedPlayerId = getCookie("chessPlayerId");
const savedPlayerIsWhite = getCookie("chessPlayerIsWhite");