Updating structure

This commit is contained in:
2026-09-26 00:48:37 -04:00
parent 6eceff8002
commit 36b3c4f2b6
116 changed files with 1947 additions and 140 deletions
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Data;
public static class DbInitializer
{
public static async Task InitializeDatabaseAsync(IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default)
{
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ZeroSpaceDbContext>();
logger.LogInformation("Ensuring database schema exists...");
await db.Database.EnsureCreatedAsync(cancellationToken);
if (!await db.Units.AnyAsync(cancellationToken))
{
logger.LogInformation("Seeding initial units into PostgreSQL...");
var initialUnits = Units.All;
await db.Units.AddRangeAsync(initialUnits, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Seeded {Count} units into PostgreSQL database.", initialUnits.Count);
}
else
{
logger.LogInformation("Database already contains unit data. Skipping seed.");
}
}
}
@@ -0,0 +1,66 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Data;
public class ZeroSpaceDbContext : DbContext
{
public ZeroSpaceDbContext(DbContextOptions<ZeroSpaceDbContext> options) : base(options)
{
}
public DbSet<UnitData> Units => Set<UnitData>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UnitData>(entity =>
{
entity.ToTable("units");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasMaxLength(100);
entity.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(200);
entity.Property(e => e.Hexite).HasColumnName("hexite");
entity.Property(e => e.Flux).HasColumnName("flux");
entity.Property(e => e.Supply).HasColumnName("supply");
entity.Property(e => e.ProductionTime).HasColumnName("production_time");
entity.Property(e => e.Health).HasColumnName("health");
entity.Property(e => e.Energy).HasColumnName("energy");
entity.Property(e => e.Shields).HasColumnName("shields");
entity.Property(e => e.ArmorRating).HasColumnName("armor_rating");
entity.Property(e => e.MovementSpeed).HasColumnName("movement_speed");
entity.Property(e => e.DamagePerSecond).HasColumnName("damage_per_second");
entity.Property(e => e.AttackRange).HasColumnName("attack_range");
entity.Property(e => e.Tier).HasColumnName("tier");
entity.Property(e => e.Faction).HasColumnName("faction").IsRequired().HasMaxLength(100);
entity.Property(e => e.Hotkey).HasColumnName("hotkey").HasMaxLength(20);
entity.Property(e => e.BuildAtSameTime).HasColumnName("build_at_same_time");
entity.Property(e => e.Limit).HasColumnName("limit");
var valueComparer = new ValueComparer<IReadOnlyList<string>>(
(c1, c2) => (c1 == null && c2 == null) || (c1 != null && c2 != null && c1.SequenceEqual(c2)),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()
);
entity.Property(e => e.Attributes)
.HasColumnName("attributes")
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => (IReadOnlyList<string>)(JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
)
.Metadata.SetValueComparer(valueComparer);
entity.Ignore(e => e.DpsPerHexite);
entity.Ignore(e => e.DpsPerFlux);
entity.Ignore(e => e.DpsPerTotalCost);
entity.Ignore(e => e.HealthPerHexite);
entity.Ignore(e => e.HealthPerFlux);
entity.Ignore(e => e.HealthPerTotalCost);
});
}
}
+21
View File
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files for caching restore layer
COPY ["ZeroSpace.ApiService/ZeroSpace.ApiService.csproj", "ZeroSpace.ApiService/"]
COPY ["ZeroSpace.Model/ZeroSpace.Model.csproj", "ZeroSpace.Model/"]
COPY ["ZeroSpace.ServiceDefaults/ZeroSpace.ServiceDefaults.csproj", "ZeroSpace.ServiceDefaults/"]
RUN dotnet restore "ZeroSpace.ApiService/ZeroSpace.ApiService.csproj"
# Copy source code and build
COPY . .
WORKDIR "/src/ZeroSpace.ApiService"
RUN dotnet publish "ZeroSpace.ApiService.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ZeroSpace.ApiService.dll"]
@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Endpoints;
public static class UnitsEndpoints
{
public static IEndpointRouteBuilder MapUnitsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/units")
.WithTags("Units");
group.MapGet("/", async (ZeroSpaceDbContext db, string? faction, int? tier) =>
{
IQueryable<UnitData> query = db.Units.AsNoTracking();
if (!string.IsNullOrWhiteSpace(faction))
{
query = query.Where(u => u.Faction == faction);
}
if (tier.HasValue)
{
query = query.Where(u => u.Tier == tier.Value);
}
var units = await query.ToListAsync();
return Results.Ok(units);
})
.WithName("GetAllUnits")
.WithSummary("Get all units optionally filtered by faction and tier.");
group.MapGet("/{id}", async (string id, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
return unit is not null ? Results.Ok(unit) : Results.NotFound();
})
.WithName("GetUnitById")
.WithSummary("Get a unit by id.");
group.MapPost("/", async (UnitData unit, ZeroSpaceDbContext db) =>
{
var exists = await db.Units.AnyAsync(u => u.Id == unit.Id);
if (exists)
{
return Results.Conflict($"Unit with id '{unit.Id}' already exists.");
}
db.Units.Add(unit);
await db.SaveChangesAsync();
return Results.Created($"/api/units/{unit.Id}", unit);
})
.WithName("CreateUnit")
.WithSummary("Create a new unit.");
group.MapPut("/{id}", async (string id, UnitData updatedUnit, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.FindAsync(id);
if (unit is null)
{
return Results.NotFound();
}
db.Units.Remove(unit);
db.Units.Add(updatedUnit with { Id = id });
await db.SaveChangesAsync();
return Results.Ok(updatedUnit with { Id = id });
})
.WithName("UpdateUnit")
.WithSummary("Update an existing unit.");
group.MapDelete("/{id}", async (string id, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.FindAsync(id);
if (unit is null)
{
return Results.NotFound();
}
db.Units.Remove(unit);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("DeleteUnit")
.WithSummary("Delete a unit by id.");
return routes;
}
}
@@ -0,0 +1,136 @@
using HotChocolate;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.GraphQL;
public record CreateUnitInput(
string Id,
string Name,
int Hexite,
int Flux,
int Supply,
int ProductionTime,
int Health,
int Energy,
int Shields,
int ArmorRating,
int MovementSpeed,
int DamagePerSecond,
int AttackRange,
IReadOnlyList<string>? Attributes,
int Tier,
string Faction,
string? Hotkey,
int BuildAtSameTime,
int? Limit
);
public record UpdateUnitInput(
string Name,
int Hexite,
int Flux,
int Supply,
int ProductionTime,
int Health,
int Energy,
int Shields,
int ArmorRating,
int MovementSpeed,
int DamagePerSecond,
int AttackRange,
IReadOnlyList<string>? Attributes,
int Tier,
string Faction,
string? Hotkey,
int BuildAtSameTime,
int? Limit
);
public class Mutation
{
public async Task<UnitData> CreateUnitAsync(CreateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var exists = await db.Units.AnyAsync(u => u.Id == input.Id, cancellationToken);
if (exists)
{
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{input.Id}' already exists.").SetCode("UNIT_ALREADY_EXISTS").Build());
}
var unit = new UnitData(
input.Id,
input.Name,
input.Hexite,
input.Flux,
input.Supply,
input.ProductionTime,
input.Health,
input.Energy,
input.Shields,
input.ArmorRating,
input.MovementSpeed,
input.DamagePerSecond,
input.AttackRange,
input.Attributes ?? new List<string>(),
input.Tier,
input.Faction,
input.Hotkey,
input.BuildAtSameTime,
input.Limit
);
db.Units.Add(unit);
await db.SaveChangesAsync(cancellationToken);
return unit;
}
public async Task<UnitData> UpdateUnitAsync(string id, UpdateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var existing = await db.Units.FindAsync(new object[] { id }, cancellationToken);
if (existing is null)
{
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{id}' not found.").SetCode("UNIT_NOT_FOUND").Build());
}
var updated = new UnitData(
id,
input.Name,
input.Hexite,
input.Flux,
input.Supply,
input.ProductionTime,
input.Health,
input.Energy,
input.Shields,
input.ArmorRating,
input.MovementSpeed,
input.DamagePerSecond,
input.AttackRange,
input.Attributes ?? new List<string>(),
input.Tier,
input.Faction,
input.Hotkey,
input.BuildAtSameTime,
input.Limit
);
db.Units.Remove(existing);
db.Units.Add(updated);
await db.SaveChangesAsync(cancellationToken);
return updated;
}
public async Task<bool> DeleteUnitAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var unit = await db.Units.FindAsync(new object[] { id }, cancellationToken);
if (unit is null)
{
return false;
}
db.Units.Remove(unit);
await db.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -0,0 +1,19 @@
using HotChocolate;
using HotChocolate.Data;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.GraphQL;
public class Query
{
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<UnitData> GetUnits(ZeroSpaceDbContext db) =>
db.Units.AsNoTracking();
public async Task<UnitData?> GetUnitByIdAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken) =>
await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
+64
View File
@@ -0,0 +1,64 @@
using ZeroSpace.ApiService.Data;
using ZeroSpace.ApiService.Endpoints;
using ZeroSpace.ApiService.GraphQL;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire components
builder.AddServiceDefaults();
// Add EF Core with PostgreSQL connection from Aspire ("zerospacedb")
builder.AddNpgsqlDbContext<ZeroSpaceDbContext>("zerospacedb");
// Add CORS for Web client
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// Configure Hot Chocolate GraphQL Server with Nitro UI (Banana Cake Pop)
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddProjections()
.AddFiltering()
.AddSorting();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseCors();
app.MapDefaultEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Map GraphQL endpoint with Nitro UI (Banana Cake Pop) at /graphql
app.MapGraphQL();
// Optional REST endpoints for auxiliary compatibility
app.MapUnitsEndpoints();
// Initialize database schema and initial data
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DbInitializer.InitializeDatabaseAsync(app.Services, app.Logger, cts.Token);
}
catch (Exception ex)
{
app.Logger.LogError(ex, "An error occurred while initializing the database.");
}
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7142;http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.5.4" />
<PackageReference Include="HotChocolate.AspNetCore" Version="16.6.7" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="16.6.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
<ProjectReference Include="..\ZeroSpace.ServiceDefaults\ZeroSpace.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}