chore: add .editorconfig and consistent formatting for backend projects
Adds an `.editorconfig` file with C# and project-specific conventions. Applies consistent indentation and formatting across backend handlers, runtime models, and AI services.
This commit is contained in:
@@ -5,12 +5,12 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/balance");
|
||||
AllowAnonymous();
|
||||
}
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/balance");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.GetBalance(), cancellationToken);
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.GetBalance(), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,44 +6,44 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/telemetry");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = worldService.GetStatus();
|
||||
var connections = worldService.GetConnectionStats();
|
||||
var uptime = telemetry.Uptime;
|
||||
|
||||
return SendOkAsync(new
|
||||
public override void Configure()
|
||||
{
|
||||
process = new
|
||||
{
|
||||
uptimeSeconds = uptime.TotalSeconds,
|
||||
cpuPercent = Math.Round(telemetry.CpuPercent, 1),
|
||||
workingSetMb = Math.Round(telemetry.WorkingSetBytes / 1_048_576.0, 1),
|
||||
gcMemoryMb = Math.Round(telemetry.GcMemoryBytes / 1_048_576.0, 1),
|
||||
threadCount = telemetry.ThreadCount,
|
||||
processorCount = Environment.ProcessorCount,
|
||||
},
|
||||
simulation = new
|
||||
{
|
||||
sequence = status.Sequence,
|
||||
connectedClients = connections.ConnectedClients,
|
||||
deltaHistoryCount = connections.DeltaHistoryCount,
|
||||
tickIntervalMs = 200,
|
||||
},
|
||||
runtime = new
|
||||
{
|
||||
frameworkDescription = RuntimeInformation.FrameworkDescription,
|
||||
osDescription = RuntimeInformation.OSDescription,
|
||||
gcGen0 = GC.CollectionCount(0),
|
||||
gcGen1 = GC.CollectionCount(1),
|
||||
gcGen2 = GC.CollectionCount(2),
|
||||
},
|
||||
}, cancellationToken);
|
||||
}
|
||||
Get("/api/telemetry");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = worldService.GetStatus();
|
||||
var connections = worldService.GetConnectionStats();
|
||||
var uptime = telemetry.Uptime;
|
||||
|
||||
return SendOkAsync(new
|
||||
{
|
||||
process = new
|
||||
{
|
||||
uptimeSeconds = uptime.TotalSeconds,
|
||||
cpuPercent = Math.Round(telemetry.CpuPercent, 1),
|
||||
workingSetMb = Math.Round(telemetry.WorkingSetBytes / 1_048_576.0, 1),
|
||||
gcMemoryMb = Math.Round(telemetry.GcMemoryBytes / 1_048_576.0, 1),
|
||||
threadCount = telemetry.ThreadCount,
|
||||
processorCount = Environment.ProcessorCount,
|
||||
},
|
||||
simulation = new
|
||||
{
|
||||
sequence = status.Sequence,
|
||||
connectedClients = connections.ConnectedClients,
|
||||
deltaHistoryCount = connections.DeltaHistoryCount,
|
||||
tickIntervalMs = 200,
|
||||
},
|
||||
runtime = new
|
||||
{
|
||||
frameworkDescription = RuntimeInformation.FrameworkDescription,
|
||||
osDescription = RuntimeInformation.OSDescription,
|
||||
gcGen0 = GC.CollectionCount(0),
|
||||
gcGen1 = GC.CollectionCount(1),
|
||||
gcGen2 = GC.CollectionCount(2),
|
||||
},
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class GetWorldHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/world");
|
||||
AllowAnonymous();
|
||||
}
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/world");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.GetSnapshot(), cancellationToken);
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.GetSnapshot(), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,20 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class GetWorldHealthHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/world/health");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = worldService.GetStatus();
|
||||
return SendOkAsync(new
|
||||
public override void Configure()
|
||||
{
|
||||
ok = true,
|
||||
sequence = status.Sequence,
|
||||
generatedAtUtc = status.GeneratedAtUtc,
|
||||
}, cancellationToken);
|
||||
}
|
||||
Get("/api/world/health");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = worldService.GetStatus();
|
||||
return SendOkAsync(new
|
||||
{
|
||||
ok = true,
|
||||
sequence = status.Sequence,
|
||||
generatedAtUtc = status.GeneratedAtUtc,
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class ResetWorldHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/world/reset");
|
||||
AllowAnonymous();
|
||||
}
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/world/reset");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.Reset(), cancellationToken);
|
||||
public override Task HandleAsync(CancellationToken cancellationToken) =>
|
||||
SendOkAsync(worldService.Reset(), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class RootRedirectHandler : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/");
|
||||
AllowAnonymous();
|
||||
}
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HttpContext.Response.Redirect("/api/world");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public override Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HttpContext.Response.Redirect("/api/world");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,49 +5,49 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class StreamWorldHandler(WorldService worldService) : EndpointWithoutRequest
|
||||
{
|
||||
private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/world/stream");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HttpContext.Response.Headers.Append("Cache-Control", "no-cache");
|
||||
HttpContext.Response.Headers.Append("Content-Type", "text/event-stream");
|
||||
|
||||
var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString();
|
||||
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
|
||||
|
||||
var scopeKind = HttpContext.Request.Query["scopeKind"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(scopeKind))
|
||||
public override void Configure()
|
||||
{
|
||||
scopeKind = HttpContext.Request.Query["scope"].ToString();
|
||||
Get("/api/world/stream");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeKind))
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
scopeKind = "universe";
|
||||
HttpContext.Response.Headers.Append("Cache-Control", "no-cache");
|
||||
HttpContext.Response.Headers.Append("Content-Type", "text/event-stream");
|
||||
|
||||
var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString();
|
||||
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
|
||||
|
||||
var scopeKind = HttpContext.Request.Query["scopeKind"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(scopeKind))
|
||||
{
|
||||
scopeKind = HttpContext.Request.Query["scope"].ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeKind))
|
||||
{
|
||||
scopeKind = "universe";
|
||||
}
|
||||
|
||||
var systemId = HttpContext.Request.Query["systemId"].ToString();
|
||||
var bubbleId = HttpContext.Request.Query["bubbleId"].ToString();
|
||||
var scope = new ObserverScope(
|
||||
scopeKind,
|
||||
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
|
||||
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
|
||||
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
|
||||
|
||||
await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
|
||||
await HttpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(delta, SseJsonOptions);
|
||||
await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
|
||||
await HttpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
var systemId = HttpContext.Request.Query["systemId"].ToString();
|
||||
var bubbleId = HttpContext.Request.Query["bubbleId"].ToString();
|
||||
var scope = new ObserverScope(
|
||||
scopeKind,
|
||||
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
|
||||
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
|
||||
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
|
||||
|
||||
await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
|
||||
await HttpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(delta, SseJsonOptions);
|
||||
await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
|
||||
await HttpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ namespace SpaceGame.Api.Universe.Api;
|
||||
|
||||
public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<BalanceDefinition>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/balance");
|
||||
AllowAnonymous();
|
||||
}
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/balance");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken)
|
||||
{
|
||||
var applied = worldService.UpdateBalance(req);
|
||||
return SendOkAsync(applied, cancellationToken);
|
||||
}
|
||||
public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken)
|
||||
{
|
||||
var applied = worldService.UpdateBalance(req);
|
||||
return SendOkAsync(applied, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,292 +5,292 @@ namespace SpaceGame.Api.Universe.Scenario;
|
||||
|
||||
internal sealed class DataCatalogLoader(string dataRoot)
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
internal ScenarioCatalog LoadCatalog()
|
||||
{
|
||||
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
|
||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
|
||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
|
||||
var balance = Read<BalanceDefinition>("balance.json");
|
||||
var recipes = BuildRecipes(items, ships, modules);
|
||||
var moduleRecipes = BuildModuleRecipes(modules);
|
||||
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
|
||||
|
||||
return new ScenarioCatalog(
|
||||
authoredSystems,
|
||||
scenario,
|
||||
balance,
|
||||
modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal),
|
||||
productionGraph);
|
||||
}
|
||||
|
||||
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyList<string> availableSystemIds)
|
||||
{
|
||||
if (availableSystemIds.Count == 0)
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
return scenario;
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
internal ScenarioCatalog LoadCatalog()
|
||||
{
|
||||
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
|
||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
|
||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
|
||||
var balance = Read<BalanceDefinition>("balance.json");
|
||||
var recipes = BuildRecipes(items, ships, modules);
|
||||
var moduleRecipes = BuildModuleRecipes(modules);
|
||||
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
|
||||
|
||||
return new ScenarioCatalog(
|
||||
authoredSystems,
|
||||
scenario,
|
||||
balance,
|
||||
modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
|
||||
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal),
|
||||
productionGraph);
|
||||
}
|
||||
|
||||
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
|
||||
? "sol"
|
||||
: availableSystemIds[0];
|
||||
|
||||
string ResolveSystemId(string systemId) =>
|
||||
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
|
||||
|
||||
return new ScenarioDefinition
|
||||
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyList<string> availableSystemIds)
|
||||
{
|
||||
InitialStations = scenario.InitialStations
|
||||
.Select(station => new InitialStationDefinition
|
||||
if (availableSystemIds.Count == 0)
|
||||
{
|
||||
SystemId = ResolveSystemId(station.SystemId),
|
||||
Label = station.Label,
|
||||
Color = station.Color,
|
||||
Objective = station.Objective,
|
||||
StartingModules = station.StartingModules.ToList(),
|
||||
FactionId = station.FactionId,
|
||||
PlanetIndex = station.PlanetIndex,
|
||||
LagrangeSide = station.LagrangeSide,
|
||||
Position = station.Position?.ToArray(),
|
||||
})
|
||||
.ToList(),
|
||||
ShipFormations = scenario.ShipFormations
|
||||
.Select(formation => new ShipFormationDefinition
|
||||
return scenario;
|
||||
}
|
||||
|
||||
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
|
||||
? "sol"
|
||||
: availableSystemIds[0];
|
||||
|
||||
string ResolveSystemId(string systemId) =>
|
||||
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
|
||||
|
||||
return new ScenarioDefinition
|
||||
{
|
||||
ShipId = formation.ShipId,
|
||||
Count = formation.Count,
|
||||
Center = formation.Center.ToArray(),
|
||||
SystemId = ResolveSystemId(formation.SystemId),
|
||||
FactionId = formation.FactionId,
|
||||
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
|
||||
})
|
||||
.ToList(),
|
||||
PatrolRoutes = scenario.PatrolRoutes
|
||||
.Select(route => new PatrolRouteDefinition
|
||||
{
|
||||
SystemId = ResolveSystemId(route.SystemId),
|
||||
Points = route.Points.Select(point => point.ToArray()).ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
MiningDefaults = new MiningDefaultsDefinition
|
||||
{
|
||||
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
|
||||
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
|
||||
},
|
||||
};
|
||||
}
|
||||
InitialStations = scenario.InitialStations
|
||||
.Select(station => new InitialStationDefinition
|
||||
{
|
||||
SystemId = ResolveSystemId(station.SystemId),
|
||||
Label = station.Label,
|
||||
Color = station.Color,
|
||||
Objective = station.Objective,
|
||||
StartingModules = station.StartingModules.ToList(),
|
||||
FactionId = station.FactionId,
|
||||
PlanetIndex = station.PlanetIndex,
|
||||
LagrangeSide = station.LagrangeSide,
|
||||
Position = station.Position?.ToArray(),
|
||||
})
|
||||
.ToList(),
|
||||
ShipFormations = scenario.ShipFormations
|
||||
.Select(formation => new ShipFormationDefinition
|
||||
{
|
||||
ShipId = formation.ShipId,
|
||||
Count = formation.Count,
|
||||
Center = formation.Center.ToArray(),
|
||||
SystemId = ResolveSystemId(formation.SystemId),
|
||||
FactionId = formation.FactionId,
|
||||
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
|
||||
})
|
||||
.ToList(),
|
||||
PatrolRoutes = scenario.PatrolRoutes
|
||||
.Select(route => new PatrolRouteDefinition
|
||||
{
|
||||
SystemId = ResolveSystemId(route.SystemId),
|
||||
Points = route.Points.Select(point => point.ToArray()).ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
MiningDefaults = new MiningDefaultsDefinition
|
||||
{
|
||||
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
|
||||
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private T Read<T>(string fileName)
|
||||
{
|
||||
var path = Path.Combine(dataRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
||||
}
|
||||
|
||||
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
|
||||
modules
|
||||
.Where(module => module.Construction is not null || module.Production.Count > 0)
|
||||
.Select(module => new ModuleRecipeDefinition
|
||||
{
|
||||
ModuleId = module.Id,
|
||||
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
|
||||
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var recipes = new List<RecipeDefinition>();
|
||||
var preferredProducerByItemId = modules
|
||||
.Where(module => module.Products.Count > 0)
|
||||
.GroupBy(module => module.Products[0], StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var item in items)
|
||||
private T Read<T>(string fileName)
|
||||
{
|
||||
if (item.Production.Count > 0)
|
||||
{
|
||||
foreach (var production in item.Production)
|
||||
var path = Path.Combine(dataRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
||||
}
|
||||
|
||||
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
|
||||
modules
|
||||
.Where(module => module.Construction is not null || module.Production.Count > 0)
|
||||
.Select(module => new ModuleRecipeDefinition
|
||||
{
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = $"{item.Id}-{production.Method}-production",
|
||||
Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})",
|
||||
FacilityCategory = InferFacilityCategory(item),
|
||||
Duration = production.Time,
|
||||
Priority = InferRecipePriority(item),
|
||||
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
|
||||
Inputs = production.Wares
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ModuleId = module.Id,
|
||||
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
|
||||
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
Outputs =
|
||||
[
|
||||
new RecipeOutputDefinition
|
||||
})
|
||||
.ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
|
||||
{
|
||||
var recipes = new List<RecipeDefinition>();
|
||||
var preferredProducerByItemId = modules
|
||||
.Where(module => module.Products.Count > 0)
|
||||
.GroupBy(module => module.Products[0], StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Production.Count > 0)
|
||||
{
|
||||
foreach (var production in item.Production)
|
||||
{
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = $"{item.Id}-{production.Method}-production",
|
||||
Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})",
|
||||
FacilityCategory = InferFacilityCategory(item),
|
||||
Duration = production.Time,
|
||||
Priority = InferRecipePriority(item),
|
||||
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
|
||||
Inputs = production.Wares
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
Outputs =
|
||||
[
|
||||
new RecipeOutputDefinition
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Amount = production.Amount,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Construction is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (item.Construction is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = item.Construction.RecipeId ?? $"{item.Id}-production",
|
||||
Label = item.Name,
|
||||
FacilityCategory = item.Construction.FacilityCategory,
|
||||
Duration = item.Construction.CycleTime,
|
||||
Priority = item.Construction.Priority,
|
||||
RequiredModules = item.Construction.RequiredModules.ToList(),
|
||||
Inputs = item.Construction.Requirements
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
Outputs =
|
||||
[
|
||||
new RecipeOutputDefinition
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = item.Construction.RecipeId ?? $"{item.Id}-production",
|
||||
Label = item.Name,
|
||||
FacilityCategory = item.Construction.FacilityCategory,
|
||||
Duration = item.Construction.CycleTime,
|
||||
Priority = item.Construction.Priority,
|
||||
RequiredModules = item.Construction.RequiredModules.ToList(),
|
||||
Inputs = item.Construction.Requirements
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
Outputs =
|
||||
[
|
||||
new RecipeOutputDefinition
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Amount = item.Construction.BatchSize,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var ship in ships)
|
||||
{
|
||||
if (ship.Construction is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction",
|
||||
Label = $"{ship.Label} Construction",
|
||||
FacilityCategory = ship.Construction.FacilityCategory,
|
||||
Duration = ship.Construction.CycleTime,
|
||||
Priority = ship.Construction.Priority,
|
||||
RequiredModules = ship.Construction.RequiredModules.ToList(),
|
||||
Inputs = ship.Construction.Requirements
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
ShipOutputId = ship.Id,
|
||||
});
|
||||
}
|
||||
|
||||
return recipes;
|
||||
}
|
||||
|
||||
foreach (var ship in ships)
|
||||
{
|
||||
if (ship.Construction is null)
|
||||
private static string InferFacilityCategory(ItemDefinition item) =>
|
||||
item.Group switch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
|
||||
_ => "station",
|
||||
};
|
||||
|
||||
recipes.Add(new RecipeDefinition
|
||||
{
|
||||
Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction",
|
||||
Label = $"{ship.Label} Construction",
|
||||
FacilityCategory = ship.Construction.FacilityCategory,
|
||||
Duration = ship.Construction.CycleTime,
|
||||
Priority = ship.Construction.Priority,
|
||||
RequiredModules = ship.Construction.RequiredModules.ToList(),
|
||||
Inputs = ship.Construction.Requirements
|
||||
.Select(input => new RecipeInputDefinition
|
||||
{
|
||||
ItemId = input.ItemId,
|
||||
Amount = input.Amount,
|
||||
})
|
||||
.ToList(),
|
||||
ShipOutputId = ship.Id,
|
||||
});
|
||||
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
|
||||
{
|
||||
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
|
||||
{
|
||||
return [moduleId];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return recipes;
|
||||
}
|
||||
private static int InferRecipePriority(ItemDefinition item) =>
|
||||
item.Group switch
|
||||
{
|
||||
"energy" => 140,
|
||||
"water" => 130,
|
||||
"food" => 120,
|
||||
"agricultural" => 110,
|
||||
"refined" => 100,
|
||||
"hightech" => 90,
|
||||
"shiptech" => 80,
|
||||
"pharmaceutical" => 70,
|
||||
_ => 60,
|
||||
};
|
||||
|
||||
private static string InferFacilityCategory(ItemDefinition item) =>
|
||||
item.Group switch
|
||||
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
|
||||
{
|
||||
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
|
||||
_ => "station",
|
||||
};
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Type))
|
||||
{
|
||||
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
|
||||
{
|
||||
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
|
||||
{
|
||||
return [moduleId];
|
||||
return items;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static int InferRecipePriority(ItemDefinition item) =>
|
||||
item.Group switch
|
||||
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
|
||||
{
|
||||
"energy" => 140,
|
||||
"water" => 130,
|
||||
"food" => 120,
|
||||
"agricultural" => 110,
|
||||
"refined" => 100,
|
||||
"hightech" => 90,
|
||||
"shiptech" => 80,
|
||||
"pharmaceutical" => 70,
|
||||
_ => 60,
|
||||
};
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
|
||||
{
|
||||
module.Products = [module.Product];
|
||||
}
|
||||
|
||||
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Type))
|
||||
{
|
||||
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(module.ProductionMode))
|
||||
{
|
||||
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
|
||||
? "commanded"
|
||||
: "passive";
|
||||
}
|
||||
|
||||
if (module.WorkforceNeeded <= 0f)
|
||||
{
|
||||
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
|
||||
{
|
||||
module.Products = [module.Product];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(module.ProductionMode))
|
||||
{
|
||||
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
|
||||
? "commanded"
|
||||
: "passive";
|
||||
}
|
||||
|
||||
if (module.WorkforceNeeded <= 0f)
|
||||
{
|
||||
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ScenarioCatalog(
|
||||
|
||||
@@ -3,18 +3,18 @@ namespace SpaceGame.Api.Universe.Scenario;
|
||||
|
||||
internal static class LoaderSupport
|
||||
{
|
||||
internal const string DefaultFactionId = "sol-dominion";
|
||||
internal const int WorldSeed = 1;
|
||||
internal const float MinimumFactionCredits = 0f;
|
||||
internal const float MinimumRefineryOre = 0f;
|
||||
internal const float MinimumRefineryStock = 0f;
|
||||
internal const float MinimumShipyardStock = 0f;
|
||||
internal const float MinimumSystemSeparation = 3.2f;
|
||||
internal const float LocalSpaceRadius = 10_000f;
|
||||
internal const string DefaultFactionId = "sol-dominion";
|
||||
internal const int WorldSeed = 1;
|
||||
internal const float MinimumFactionCredits = 0f;
|
||||
internal const float MinimumRefineryOre = 0f;
|
||||
internal const float MinimumRefineryStock = 0f;
|
||||
internal const float MinimumShipyardStock = 0f;
|
||||
internal const float MinimumSystemSeparation = 3.2f;
|
||||
internal const float LocalSpaceRadius = 10_000f;
|
||||
|
||||
internal static readonly string[] GeneratedSystemNames =
|
||||
[
|
||||
"Aquila Verge",
|
||||
internal static readonly string[] GeneratedSystemNames =
|
||||
[
|
||||
"Aquila Verge",
|
||||
"Orion Fold",
|
||||
"Draco Span",
|
||||
"Lyra Shoal",
|
||||
@@ -48,9 +48,9 @@ internal static class LoaderSupport
|
||||
"Telescopium Strand",
|
||||
];
|
||||
|
||||
internal static readonly StarProfile[] StarProfiles =
|
||||
[
|
||||
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
|
||||
internal static readonly StarProfile[] StarProfiles =
|
||||
[
|
||||
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
|
||||
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
|
||||
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
|
||||
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
|
||||
@@ -59,9 +59,9 @@ internal static class LoaderSupport
|
||||
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
|
||||
];
|
||||
|
||||
internal static readonly PlanetProfile[] PlanetProfiles =
|
||||
[
|
||||
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
|
||||
internal static readonly PlanetProfile[] PlanetProfiles =
|
||||
[
|
||||
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
|
||||
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
|
||||
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
|
||||
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
|
||||
@@ -71,85 +71,85 @@ internal static class LoaderSupport
|
||||
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
|
||||
];
|
||||
|
||||
internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||
internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||
|
||||
internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
|
||||
{
|
||||
var raw = ToVector(values);
|
||||
var relativeToSystem = new Vector3(
|
||||
raw.X - system.Position.X,
|
||||
raw.Y - system.Position.Y,
|
||||
raw.Z - system.Position.Z);
|
||||
|
||||
return relativeToSystem.LengthSquared() < raw.LengthSquared()
|
||||
? relativeToSystem
|
||||
: raw;
|
||||
}
|
||||
|
||||
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
|
||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||
|
||||
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
||||
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
|
||||
|
||||
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
|
||||
{
|
||||
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
|
||||
internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
|
||||
{
|
||||
return;
|
||||
var raw = ToVector(values);
|
||||
var relativeToSystem = new Vector3(
|
||||
raw.X - system.Position.X,
|
||||
raw.Y - system.Position.Y,
|
||||
raw.Z - system.Position.Z);
|
||||
|
||||
return relativeToSystem.LengthSquared() < raw.LengthSquared()
|
||||
? relativeToSystem
|
||||
: raw;
|
||||
}
|
||||
|
||||
station.Modules.Add(new StationModuleRuntime
|
||||
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
|
||||
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
|
||||
|
||||
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
|
||||
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
|
||||
|
||||
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
|
||||
{
|
||||
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
|
||||
ModuleId = moduleId,
|
||||
Health = definition.Hull,
|
||||
MaxHealth = definition.Hull,
|
||||
});
|
||||
station.Radius = GetStationRadius(moduleDefinitions, station);
|
||||
}
|
||||
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
internal static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
|
||||
{
|
||||
var totalArea = station.Modules
|
||||
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
|
||||
.Sum();
|
||||
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
|
||||
}
|
||||
|
||||
internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
||||
|
||||
internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
|
||||
|
||||
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
||||
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
||||
|
||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
if (workforceRequired <= 0.01f)
|
||||
{
|
||||
return 1f;
|
||||
station.Modules.Add(new StationModuleRuntime
|
||||
{
|
||||
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
|
||||
ModuleId = moduleId,
|
||||
Health = definition.Hull,
|
||||
MaxHealth = definition.Hull,
|
||||
});
|
||||
station.Radius = GetStationRadius(moduleDefinitions, station);
|
||||
}
|
||||
|
||||
var staffedRatio = MathF.Min(1f, population / workforceRequired);
|
||||
return 0.1f + (0.9f * staffedRatio);
|
||||
}
|
||||
|
||||
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||
|
||||
internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
|
||||
|
||||
internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
|
||||
{
|
||||
var length = MathF.Sqrt(vector.LengthSquared());
|
||||
if (length <= 0.0001f)
|
||||
internal static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
|
||||
{
|
||||
return fallback;
|
||||
var totalArea = station.Modules
|
||||
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
|
||||
.Sum();
|
||||
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
|
||||
}
|
||||
|
||||
return vector.Divide(length);
|
||||
}
|
||||
internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
||||
|
||||
internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
|
||||
|
||||
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
||||
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
||||
|
||||
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
if (workforceRequired <= 0.01f)
|
||||
{
|
||||
return 1f;
|
||||
}
|
||||
|
||||
var staffedRatio = MathF.Min(1f, population / workforceRequired);
|
||||
return 0.1f + (0.9f * staffedRatio);
|
||||
}
|
||||
|
||||
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||
|
||||
internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
|
||||
|
||||
internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
|
||||
{
|
||||
var length = MathF.Sqrt(vector.LengthSquared());
|
||||
if (length <= 0.0001f)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return vector.Divide(length);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record StarProfile(
|
||||
@@ -167,5 +167,5 @@ internal sealed record PlanetProfile(
|
||||
int BaseMoonCount,
|
||||
bool CanHaveRing)
|
||||
{
|
||||
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
|
||||
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,24 @@ namespace SpaceGame.Api.Universe.Scenario;
|
||||
|
||||
public sealed class ScenarioLoader
|
||||
{
|
||||
private readonly WorldBuilder _worldBuilder;
|
||||
private readonly WorldBuilder _worldBuilder;
|
||||
|
||||
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
|
||||
{
|
||||
var generationOptions = worldGeneration ?? new WorldGenerationOptions();
|
||||
var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
|
||||
var dataLoader = new DataCatalogLoader(dataRoot);
|
||||
var generationService = new SystemGenerationService();
|
||||
var spatialBuilder = new SpatialBuilder();
|
||||
var seedingService = new WorldSeedingService();
|
||||
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
|
||||
{
|
||||
var generationOptions = worldGeneration ?? new WorldGenerationOptions();
|
||||
var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
|
||||
var dataLoader = new DataCatalogLoader(dataRoot);
|
||||
var generationService = new SystemGenerationService();
|
||||
var spatialBuilder = new SpatialBuilder();
|
||||
var seedingService = new WorldSeedingService();
|
||||
|
||||
_worldBuilder = new WorldBuilder(
|
||||
generationOptions,
|
||||
dataLoader,
|
||||
generationService,
|
||||
spatialBuilder,
|
||||
seedingService);
|
||||
}
|
||||
_worldBuilder = new WorldBuilder(
|
||||
generationOptions,
|
||||
dataLoader,
|
||||
generationService,
|
||||
spatialBuilder,
|
||||
seedingService);
|
||||
}
|
||||
|
||||
public SimulationWorld Load() => _worldBuilder.Build();
|
||||
public SimulationWorld Load() => _worldBuilder.Build();
|
||||
}
|
||||
|
||||
@@ -4,305 +4,305 @@ namespace SpaceGame.Api.Universe.Scenario;
|
||||
|
||||
internal sealed class SpatialBuilder
|
||||
{
|
||||
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
|
||||
{
|
||||
var systemGraphs = systems.ToDictionary(
|
||||
system => system.Definition.Id,
|
||||
BuildSystemSpatialGraph,
|
||||
StringComparer.Ordinal);
|
||||
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
|
||||
var nodes = new List<ResourceNodeRuntime>();
|
||||
var nodeIdCounter = 0;
|
||||
|
||||
foreach (var system in systems)
|
||||
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
|
||||
{
|
||||
var systemGraph = systemGraphs[system.Definition.Id];
|
||||
foreach (var node in system.Definition.ResourceNodes)
|
||||
{
|
||||
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
|
||||
nodes.Add(new ResourceNodeRuntime
|
||||
var systemGraphs = systems.ToDictionary(
|
||||
system => system.Definition.Id,
|
||||
BuildSystemSpatialGraph,
|
||||
StringComparer.Ordinal);
|
||||
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
|
||||
var nodes = new List<ResourceNodeRuntime>();
|
||||
var nodeIdCounter = 0;
|
||||
|
||||
foreach (var system in systems)
|
||||
{
|
||||
Id = $"node-{++nodeIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
|
||||
SourceKind = node.SourceKind,
|
||||
ItemId = node.ItemId,
|
||||
CelestialId = anchorCelestial?.Id,
|
||||
OrbitRadius = node.RadiusOffset,
|
||||
OrbitPhase = node.Angle,
|
||||
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
|
||||
OreRemaining = node.OreAmount,
|
||||
MaxOre = node.OreAmount,
|
||||
});
|
||||
}
|
||||
var systemGraph = systemGraphs[system.Definition.Id];
|
||||
foreach (var node in system.Definition.ResourceNodes)
|
||||
{
|
||||
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
|
||||
nodes.Add(new ResourceNodeRuntime
|
||||
{
|
||||
Id = $"node-{++nodeIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
|
||||
SourceKind = node.SourceKind,
|
||||
ItemId = node.ItemId,
|
||||
CelestialId = anchorCelestial?.Id,
|
||||
OrbitRadius = node.RadiusOffset,
|
||||
OrbitPhase = node.Angle,
|
||||
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
|
||||
OreRemaining = node.OreAmount,
|
||||
MaxOre = node.OreAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes);
|
||||
}
|
||||
|
||||
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes);
|
||||
}
|
||||
|
||||
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
|
||||
{
|
||||
var celestials = new List<CelestialRuntime>();
|
||||
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, CelestialRuntime>>();
|
||||
|
||||
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
|
||||
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
|
||||
{
|
||||
AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-star-{starIndex + 1}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Star,
|
||||
position: Vector3.Zero,
|
||||
localSpaceRadius: LocalSpaceRadius);
|
||||
var celestials = new List<CelestialRuntime>();
|
||||
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, CelestialRuntime>>();
|
||||
|
||||
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
|
||||
{
|
||||
AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-star-{starIndex + 1}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Star,
|
||||
position: Vector3.Zero,
|
||||
localSpaceRadius: LocalSpaceRadius);
|
||||
}
|
||||
|
||||
var primaryStarNodeId = $"node-{system.Definition.Id}-star-1";
|
||||
|
||||
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
|
||||
{
|
||||
var planet = system.Definition.Planets[planetIndex];
|
||||
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
|
||||
var planetPosition = ComputePlanetPosition(planet);
|
||||
var planetCelestial = AddCelestial(
|
||||
celestials,
|
||||
id: planetNodeId,
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Planet,
|
||||
position: planetPosition,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: primaryStarNodeId);
|
||||
|
||||
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
|
||||
{
|
||||
var lagrangeCelestial = AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.LagrangePoint,
|
||||
position: point.Position,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: planetCelestial.Id,
|
||||
orbitReferenceId: point.Designation);
|
||||
lagrangeNodes[point.Designation] = lagrangeCelestial;
|
||||
}
|
||||
|
||||
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
|
||||
|
||||
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
|
||||
{
|
||||
var moon = planet.Moons[moonIndex];
|
||||
var moonPosition = ComputeMoonPosition(planetPosition, moon);
|
||||
AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Moon,
|
||||
position: moonPosition,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: planetCelestial.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex);
|
||||
}
|
||||
|
||||
var primaryStarNodeId = $"node-{system.Definition.Id}-star-1";
|
||||
|
||||
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
|
||||
private static CelestialRuntime AddCelestial(
|
||||
ICollection<CelestialRuntime> celestials,
|
||||
string id,
|
||||
string systemId,
|
||||
SpatialNodeKind kind,
|
||||
Vector3 position,
|
||||
float localSpaceRadius,
|
||||
string? parentNodeId = null,
|
||||
string? orbitReferenceId = null)
|
||||
{
|
||||
var planet = system.Definition.Planets[planetIndex];
|
||||
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
|
||||
var planetPosition = ComputePlanetPosition(planet);
|
||||
var planetCelestial = AddCelestial(
|
||||
celestials,
|
||||
id: planetNodeId,
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Planet,
|
||||
position: planetPosition,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: primaryStarNodeId);
|
||||
var celestial = new CelestialRuntime
|
||||
{
|
||||
Id = id,
|
||||
SystemId = systemId,
|
||||
Kind = kind,
|
||||
Position = position,
|
||||
LocalSpaceRadius = localSpaceRadius,
|
||||
ParentNodeId = parentNodeId,
|
||||
OrbitReferenceId = orbitReferenceId,
|
||||
};
|
||||
|
||||
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
|
||||
{
|
||||
var lagrangeCelestial = AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.LagrangePoint,
|
||||
position: point.Position,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: planetCelestial.Id,
|
||||
orbitReferenceId: point.Designation);
|
||||
lagrangeNodes[point.Designation] = lagrangeCelestial;
|
||||
}
|
||||
|
||||
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
|
||||
|
||||
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
|
||||
{
|
||||
var moon = planet.Moons[moonIndex];
|
||||
var moonPosition = ComputeMoonPosition(planetPosition, moon);
|
||||
AddCelestial(
|
||||
celestials,
|
||||
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
|
||||
systemId: system.Definition.Id,
|
||||
kind: SpatialNodeKind.Moon,
|
||||
position: moonPosition,
|
||||
localSpaceRadius: LocalSpaceRadius,
|
||||
parentNodeId: planetCelestial.Id);
|
||||
}
|
||||
celestials.Add(celestial);
|
||||
return celestial;
|
||||
}
|
||||
|
||||
return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex);
|
||||
}
|
||||
|
||||
private static CelestialRuntime AddCelestial(
|
||||
ICollection<CelestialRuntime> celestials,
|
||||
string id,
|
||||
string systemId,
|
||||
SpatialNodeKind kind,
|
||||
Vector3 position,
|
||||
float localSpaceRadius,
|
||||
string? parentNodeId = null,
|
||||
string? orbitReferenceId = null)
|
||||
{
|
||||
var celestial = new CelestialRuntime
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
|
||||
{
|
||||
Id = id,
|
||||
SystemId = systemId,
|
||||
Kind = kind,
|
||||
Position = position,
|
||||
LocalSpaceRadius = localSpaceRadius,
|
||||
ParentNodeId = parentNodeId,
|
||||
OrbitReferenceId = orbitReferenceId,
|
||||
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
||||
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
||||
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
|
||||
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
|
||||
var triangularAngle = MathF.PI / 3f;
|
||||
|
||||
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
|
||||
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
|
||||
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(
|
||||
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(
|
||||
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
|
||||
{
|
||||
var planetMassProxy = EstimatePlanetMassRatio(planet);
|
||||
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
|
||||
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
|
||||
return MathF.Max(minimumOffset, hillLikeOffset);
|
||||
}
|
||||
|
||||
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
|
||||
{
|
||||
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
|
||||
var densityFactor = planet.PlanetType switch
|
||||
{
|
||||
"gas-giant" => 0.24f,
|
||||
"ice-giant" => 0.18f,
|
||||
"oceanic" => 0.95f,
|
||||
"ice" => 0.7f,
|
||||
_ => 1f,
|
||||
};
|
||||
|
||||
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
|
||||
return earthMasses / 332_946f;
|
||||
}
|
||||
|
||||
internal static StationPlacement ResolveStationPlacement(
|
||||
InitialStationDefinition plan,
|
||||
SystemRuntime system,
|
||||
SystemSpatialGraph graph,
|
||||
IReadOnlyCollection<CelestialRuntime> existingCelestials)
|
||||
{
|
||||
if (plan.PlanetIndex is int planetIndex &&
|
||||
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
|
||||
{
|
||||
var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
|
||||
if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial))
|
||||
{
|
||||
return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.Position is { Length: 3 })
|
||||
{
|
||||
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
|
||||
var preferredCelestial = existingCelestials
|
||||
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
|
||||
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||
.FirstOrDefault()
|
||||
?? existingCelestials
|
||||
.Where(c => c.SystemId == system.Definition.Id)
|
||||
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||
.First();
|
||||
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
|
||||
}
|
||||
|
||||
var fallbackCelestial = graph.Celestials
|
||||
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
|
||||
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
|
||||
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
|
||||
}
|
||||
|
||||
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
|
||||
{
|
||||
< 0 => "L4",
|
||||
> 0 => "L5",
|
||||
_ => "L1",
|
||||
};
|
||||
|
||||
celestials.Add(celestial);
|
||||
return celestial;
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
|
||||
{
|
||||
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
||||
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
||||
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
|
||||
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
|
||||
var triangularAngle = MathF.PI / 3f;
|
||||
|
||||
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
|
||||
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
|
||||
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(
|
||||
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(
|
||||
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
|
||||
{
|
||||
var planetMassProxy = EstimatePlanetMassRatio(planet);
|
||||
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
|
||||
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
|
||||
return MathF.Max(minimumOffset, hillLikeOffset);
|
||||
}
|
||||
|
||||
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
|
||||
{
|
||||
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
|
||||
var densityFactor = planet.PlanetType switch
|
||||
private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
|
||||
{
|
||||
"gas-giant" => 0.24f,
|
||||
"ice-giant" => 0.18f,
|
||||
"oceanic" => 0.95f,
|
||||
"ice" => 0.7f,
|
||||
_ => 1f,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(definition.AnchorReference))
|
||||
{
|
||||
var anchorId = definition.AnchorReference.ToLowerInvariant() switch
|
||||
{
|
||||
var reference when reference.StartsWith("star-", StringComparison.Ordinal)
|
||||
=> $"node-{graph.SystemId}-{reference}",
|
||||
var reference when reference.StartsWith("planet-", StringComparison.Ordinal)
|
||||
=> $"node-{graph.SystemId}-{reference}",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
|
||||
return earthMasses / 332_946f;
|
||||
}
|
||||
if (anchorId is not null)
|
||||
{
|
||||
return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
internal static StationPlacement ResolveStationPlacement(
|
||||
InitialStationDefinition plan,
|
||||
SystemRuntime system,
|
||||
SystemSpatialGraph graph,
|
||||
IReadOnlyCollection<CelestialRuntime> existingCelestials)
|
||||
{
|
||||
if (plan.PlanetIndex is int planetIndex &&
|
||||
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
|
||||
{
|
||||
var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
|
||||
if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial))
|
||||
{
|
||||
return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position);
|
||||
}
|
||||
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
|
||||
{
|
||||
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
||||
return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId);
|
||||
}
|
||||
|
||||
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
|
||||
return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
|
||||
}
|
||||
|
||||
if (plan.Position is { Length: 3 })
|
||||
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane)
|
||||
{
|
||||
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
|
||||
var preferredCelestial = existingCelestials
|
||||
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
|
||||
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||
.FirstOrDefault()
|
||||
?? existingCelestials
|
||||
.Where(c => c.SystemId == system.Definition.Id)
|
||||
.OrderBy(c => c.Position.DistanceTo(targetPosition))
|
||||
.First();
|
||||
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
|
||||
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f);
|
||||
var offset = new Vector3(
|
||||
MathF.Cos(definition.Angle) * definition.RadiusOffset,
|
||||
verticalOffset,
|
||||
MathF.Sin(definition.Angle) * definition.RadiusOffset);
|
||||
|
||||
if (anchorCelestial is null)
|
||||
{
|
||||
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
|
||||
}
|
||||
|
||||
return Add(anchorCelestial.Position, offset);
|
||||
}
|
||||
|
||||
var fallbackCelestial = graph.Celestials
|
||||
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
|
||||
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
|
||||
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
|
||||
}
|
||||
|
||||
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
|
||||
{
|
||||
< 0 => "L4",
|
||||
> 0 => "L5",
|
||||
_ => "L1",
|
||||
};
|
||||
|
||||
private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(definition.AnchorReference))
|
||||
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
|
||||
{
|
||||
var anchorId = definition.AnchorReference.ToLowerInvariant() switch
|
||||
{
|
||||
var reference when reference.StartsWith("star-", StringComparison.Ordinal)
|
||||
=> $"node-{graph.SystemId}-{reference}",
|
||||
var reference when reference.StartsWith("planet-", StringComparison.Ordinal)
|
||||
=> $"node-{graph.SystemId}-{reference}",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (anchorId is not null)
|
||||
{
|
||||
return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal));
|
||||
}
|
||||
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
|
||||
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
|
||||
return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
|
||||
}
|
||||
|
||||
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
|
||||
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
|
||||
{
|
||||
return null;
|
||||
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch);
|
||||
var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius);
|
||||
return Add(planetPosition, local);
|
||||
}
|
||||
|
||||
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
|
||||
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
|
||||
{
|
||||
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
||||
return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId);
|
||||
var nearestCelestial = celestials
|
||||
.Where(c => c.SystemId == systemId)
|
||||
.OrderBy(c => c.Position.DistanceTo(position))
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ShipSpatialStateRuntime
|
||||
{
|
||||
CurrentSystemId = systemId,
|
||||
SpaceLayer = SpaceLayerKinds.LocalSpace,
|
||||
CurrentCelestialId = nearestCelestial?.Id,
|
||||
LocalPosition = position,
|
||||
SystemPosition = position,
|
||||
MovementRegime = MovementRegimeKinds.LocalFlight,
|
||||
};
|
||||
}
|
||||
|
||||
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
|
||||
return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
|
||||
}
|
||||
|
||||
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane)
|
||||
{
|
||||
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f);
|
||||
var offset = new Vector3(
|
||||
MathF.Cos(definition.Angle) * definition.RadiusOffset,
|
||||
verticalOffset,
|
||||
MathF.Sin(definition.Angle) * definition.RadiusOffset);
|
||||
|
||||
if (anchorCelestial is null)
|
||||
{
|
||||
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
|
||||
}
|
||||
|
||||
return Add(anchorCelestial.Position, offset);
|
||||
}
|
||||
|
||||
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
|
||||
{
|
||||
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
|
||||
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
|
||||
return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
|
||||
}
|
||||
|
||||
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
|
||||
{
|
||||
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch);
|
||||
var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius);
|
||||
return Add(planetPosition, local);
|
||||
}
|
||||
|
||||
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
|
||||
{
|
||||
var nearestCelestial = celestials
|
||||
.Where(c => c.SystemId == systemId)
|
||||
.OrderBy(c => c.Position.DistanceTo(position))
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ShipSpatialStateRuntime
|
||||
{
|
||||
CurrentSystemId = systemId,
|
||||
SpaceLayer = SpaceLayerKinds.LocalSpace,
|
||||
CurrentCelestialId = nearestCelestial?.Id,
|
||||
LocalPosition = position,
|
||||
SystemPosition = position,
|
||||
MovementRegime = MovementRegimeKinds.LocalFlight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ScenarioSpatialLayout(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,335 +9,335 @@ internal sealed class WorldBuilder(
|
||||
SpatialBuilder spatialBuilder,
|
||||
WorldSeedingService seedingService)
|
||||
{
|
||||
internal SimulationWorld Build()
|
||||
{
|
||||
var catalog = dataLoader.LoadCatalog();
|
||||
var systems = generationService.ExpandSystems(
|
||||
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
|
||||
worldGeneration.TargetSystemCount);
|
||||
|
||||
Console.WriteLine("TEST");
|
||||
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
|
||||
|
||||
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
|
||||
catalog.Scenario,
|
||||
systems.Select(system => system.Id).ToList());
|
||||
|
||||
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
|
||||
|
||||
var systemRuntimes = systems
|
||||
.Select(definition => new SystemRuntime
|
||||
{
|
||||
Definition = definition,
|
||||
Position = ToVector(definition.Position),
|
||||
})
|
||||
.ToList();
|
||||
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
|
||||
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
|
||||
|
||||
var stations = CreateStations(
|
||||
scenario,
|
||||
systemsById,
|
||||
spatialLayout.SystemGraphs,
|
||||
spatialLayout.Celestials,
|
||||
catalog.ModuleDefinitions,
|
||||
catalog.ItemDefinitions);
|
||||
|
||||
seedingService.InitializeStationStockpiles(stations);
|
||||
var refinery = seedingService.SelectRefineryStation(stations, scenario);
|
||||
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
|
||||
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
|
||||
|
||||
if (worldGeneration.AiControllerFactionCount < int.MaxValue)
|
||||
internal SimulationWorld Build()
|
||||
{
|
||||
var aiFactionIds = stations
|
||||
.Select(s => s.FactionId)
|
||||
.Concat(ships.Select(s => s.FactionId))
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.Take(worldGeneration.AiControllerFactionCount)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
aiFactionIds.Add(DefaultFactionId);
|
||||
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
|
||||
ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
|
||||
}
|
||||
var catalog = dataLoader.LoadCatalog();
|
||||
var systems = generationService.ExpandSystems(
|
||||
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
|
||||
worldGeneration.TargetSystemCount);
|
||||
|
||||
var factions = seedingService.CreateFactions(stations, ships);
|
||||
seedingService.BootstrapFactionEconomy(factions, stations);
|
||||
var policies = seedingService.CreatePolicies(factions);
|
||||
var commanders = seedingService.CreateCommanders(factions, stations, ships);
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var playerFaction = worldGeneration.GeneratePlayerFaction
|
||||
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
|
||||
: null;
|
||||
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
|
||||
var bootstrapWorld = new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Bootstrap World",
|
||||
Seed = WorldSeed,
|
||||
Balance = catalog.Balance,
|
||||
Systems = systemRuntimes,
|
||||
Celestials = spatialLayout.Celestials,
|
||||
Nodes = spatialLayout.Nodes,
|
||||
Wrecks = [],
|
||||
Stations = stations,
|
||||
Ships = ships,
|
||||
Factions = factions,
|
||||
PlayerFaction = playerFaction,
|
||||
Commanders = commanders,
|
||||
Claims = claims,
|
||||
ConstructionSites = [],
|
||||
MarketOrders = [],
|
||||
Policies = policies,
|
||||
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
|
||||
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = nowUtc,
|
||||
};
|
||||
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld);
|
||||
Console.WriteLine("TEST");
|
||||
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
|
||||
|
||||
var world = new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Simulation World",
|
||||
Seed = WorldSeed,
|
||||
Balance = catalog.Balance,
|
||||
Systems = systemRuntimes,
|
||||
Celestials = spatialLayout.Celestials,
|
||||
Nodes = spatialLayout.Nodes,
|
||||
Wrecks = [],
|
||||
Stations = stations,
|
||||
Ships = ships,
|
||||
Factions = factions,
|
||||
PlayerFaction = playerFaction,
|
||||
Geopolitics = null,
|
||||
Commanders = commanders,
|
||||
Claims = claims,
|
||||
ConstructionSites = constructionSites,
|
||||
MarketOrders = marketOrders,
|
||||
Policies = policies,
|
||||
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
|
||||
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
|
||||
catalog.Scenario,
|
||||
systems.Select(system => system.Id).ToList());
|
||||
|
||||
var geopolitics = new GeopoliticalSimulationService();
|
||||
geopolitics.Update(world, 0f, []);
|
||||
return world;
|
||||
}
|
||||
Console.WriteLine(string.Join(',', systems.Select(s => s.Id)));
|
||||
|
||||
private static List<StationRuntime> CreateStations(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById,
|
||||
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
|
||||
IReadOnlyCollection<CelestialRuntime> celestials,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
var stations = new List<StationRuntime>();
|
||||
var stationIdCounter = 0;
|
||||
|
||||
foreach (var plan in scenario.InitialStations)
|
||||
{
|
||||
if (!systemsById.TryGetValue(plan.SystemId, out var system))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
|
||||
var station = new StationRuntime
|
||||
{
|
||||
Id = $"station-{++stationIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Label = plan.Label,
|
||||
Color = plan.Color,
|
||||
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
|
||||
Position = placement.Position,
|
||||
FactionId = plan.FactionId ?? DefaultFactionId,
|
||||
CelestialId = placement.AnchorCelestial.Id,
|
||||
Health = 600f,
|
||||
MaxHealth = 600f,
|
||||
};
|
||||
|
||||
stations.Add(station);
|
||||
placement.AnchorCelestial.OccupyingStructureId = station.Id;
|
||||
|
||||
var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions);
|
||||
|
||||
foreach (var moduleId in startingModules)
|
||||
{
|
||||
AddStationModule(station, moduleDefinitions, moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
return stations;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildStartingModules(
|
||||
InitialStationDefinition plan,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
var startingModules = new List<string>(plan.StartingModules.Count > 0
|
||||
? plan.StartingModules
|
||||
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]);
|
||||
|
||||
EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech");
|
||||
|
||||
var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective);
|
||||
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
|
||||
{
|
||||
EnsureStartingModule(startingModules, objectiveModuleId);
|
||||
|
||||
if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
|
||||
{
|
||||
EnsureStartingModule(startingModules, "module_gen_prod_energycells_01");
|
||||
}
|
||||
|
||||
foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions))
|
||||
{
|
||||
EnsureStartingModule(startingModules, storageModuleId);
|
||||
}
|
||||
}
|
||||
|
||||
return startingModules;
|
||||
}
|
||||
|
||||
private static string? GetObjectiveStartingModuleId(string? objective) =>
|
||||
StationSimulationService.NormalizeStationObjective(objective) switch
|
||||
{
|
||||
"power" => "module_gen_prod_energycells_01",
|
||||
"refinery" => "module_gen_ref_ore_01",
|
||||
"graphene" => "module_gen_prod_graphene_01",
|
||||
"siliconwafers" => "module_gen_prod_siliconwafers_01",
|
||||
"hullparts" => "module_gen_prod_hullparts_01",
|
||||
"claytronics" => "module_gen_prod_claytronics_01",
|
||||
"quantumtubes" => "module_gen_prod_quantumtubes_01",
|
||||
"antimattercells" => "module_gen_prod_antimattercells_01",
|
||||
"superfluidcoolant" => "module_gen_prod_superfluidcoolant_01",
|
||||
"water" => "module_gen_prod_water_01",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IEnumerable<string> GetRequiredStartingStorageModules(
|
||||
string moduleId,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var wareId in moduleDefinition.Production
|
||||
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
|
||||
.Concat(moduleDefinition.Products)
|
||||
.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var storageModuleId = itemDefinition.CargoKind switch
|
||||
{
|
||||
"solid" => "module_arg_stor_solid_m_01",
|
||||
"liquid" => "module_arg_stor_liquid_m_01",
|
||||
_ => "module_arg_stor_container_m_01",
|
||||
};
|
||||
|
||||
yield return storageModuleId;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureStartingModule(List<string> modules, string moduleId)
|
||||
{
|
||||
if (!modules.Contains(moduleId, StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById)
|
||||
{
|
||||
return scenario.PatrolRoutes
|
||||
.GroupBy(route => route.SystemId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.SelectMany(route => route.Points)
|
||||
.Select(point => NormalizeScenarioPoint(systemsById[group.Key], point))
|
||||
.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static List<ShipRuntime> CreateShips(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById,
|
||||
IReadOnlyCollection<CelestialRuntime> celestials,
|
||||
BalanceDefinition balance,
|
||||
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
IReadOnlyCollection<StationRuntime> stations,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
var ships = new List<ShipRuntime>();
|
||||
var shipIdCounter = 0;
|
||||
|
||||
foreach (var formation in scenario.ShipFormations)
|
||||
{
|
||||
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var index = 0; index < formation.Count; index += 1)
|
||||
{
|
||||
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
|
||||
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
|
||||
|
||||
ships.Add(new ShipRuntime
|
||||
{
|
||||
Id = $"ship-{++shipIdCounter}",
|
||||
SystemId = formation.SystemId,
|
||||
Definition = definition,
|
||||
FactionId = formation.FactionId ?? DefaultFactionId,
|
||||
Position = position,
|
||||
TargetPosition = position,
|
||||
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
|
||||
DefaultBehavior = WorldSeedingService.CreateBehavior(
|
||||
definition,
|
||||
formation.SystemId,
|
||||
formation.FactionId ?? DefaultFactionId,
|
||||
scenario,
|
||||
patrolRoutes,
|
||||
stations,
|
||||
refinery),
|
||||
Skills = WorldSeedingService.CreateSkills(definition),
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
|
||||
foreach (var (itemId, amount) in formation.StartingInventory)
|
||||
{
|
||||
if (amount > 0f)
|
||||
var systemRuntimes = systems
|
||||
.Select(definition => new SystemRuntime
|
||||
{
|
||||
ships[^1].Inventory[itemId] = amount;
|
||||
}
|
||||
Definition = definition,
|
||||
Position = ToVector(definition.Position),
|
||||
})
|
||||
.ToList();
|
||||
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
|
||||
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
|
||||
|
||||
var stations = CreateStations(
|
||||
scenario,
|
||||
systemsById,
|
||||
spatialLayout.SystemGraphs,
|
||||
spatialLayout.Celestials,
|
||||
catalog.ModuleDefinitions,
|
||||
catalog.ItemDefinitions);
|
||||
|
||||
seedingService.InitializeStationStockpiles(stations);
|
||||
var refinery = seedingService.SelectRefineryStation(stations, scenario);
|
||||
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
|
||||
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
|
||||
|
||||
if (worldGeneration.AiControllerFactionCount < int.MaxValue)
|
||||
{
|
||||
var aiFactionIds = stations
|
||||
.Select(s => s.FactionId)
|
||||
.Concat(ships.Select(s => s.FactionId))
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.Take(worldGeneration.AiControllerFactionCount)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
aiFactionIds.Add(DefaultFactionId);
|
||||
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
|
||||
ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
var factions = seedingService.CreateFactions(stations, ships);
|
||||
seedingService.BootstrapFactionEconomy(factions, stations);
|
||||
var policies = seedingService.CreatePolicies(factions);
|
||||
var commanders = seedingService.CreateCommanders(factions, stations, ships);
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var playerFaction = worldGeneration.GeneratePlayerFaction
|
||||
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
|
||||
: null;
|
||||
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
|
||||
var bootstrapWorld = new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Bootstrap World",
|
||||
Seed = WorldSeed,
|
||||
Balance = catalog.Balance,
|
||||
Systems = systemRuntimes,
|
||||
Celestials = spatialLayout.Celestials,
|
||||
Nodes = spatialLayout.Nodes,
|
||||
Wrecks = [],
|
||||
Stations = stations,
|
||||
Ships = ships,
|
||||
Factions = factions,
|
||||
PlayerFaction = playerFaction,
|
||||
Commanders = commanders,
|
||||
Claims = claims,
|
||||
ConstructionSites = [],
|
||||
MarketOrders = [],
|
||||
Policies = policies,
|
||||
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
|
||||
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = nowUtc,
|
||||
};
|
||||
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld);
|
||||
|
||||
var world = new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Simulation World",
|
||||
Seed = WorldSeed,
|
||||
Balance = catalog.Balance,
|
||||
Systems = systemRuntimes,
|
||||
Celestials = spatialLayout.Celestials,
|
||||
Nodes = spatialLayout.Nodes,
|
||||
Wrecks = [],
|
||||
Stations = stations,
|
||||
Ships = ships,
|
||||
Factions = factions,
|
||||
PlayerFaction = playerFaction,
|
||||
Geopolitics = null,
|
||||
Commanders = commanders,
|
||||
Claims = claims,
|
||||
ConstructionSites = constructionSites,
|
||||
MarketOrders = marketOrders,
|
||||
Policies = policies,
|
||||
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
|
||||
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
|
||||
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
|
||||
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
|
||||
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
|
||||
ProductionGraph = catalog.ProductionGraph,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
var geopolitics = new GeopoliticalSimulationService();
|
||||
geopolitics.Update(world, 0f, []);
|
||||
return world;
|
||||
}
|
||||
|
||||
return ships;
|
||||
}
|
||||
private static List<StationRuntime> CreateStations(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById,
|
||||
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
|
||||
IReadOnlyCollection<CelestialRuntime> celestials,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
var stations = new List<StationRuntime>();
|
||||
var stationIdCounter = 0;
|
||||
|
||||
foreach (var plan in scenario.InitialStations)
|
||||
{
|
||||
if (!systemsById.TryGetValue(plan.SystemId, out var system))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
|
||||
var station = new StationRuntime
|
||||
{
|
||||
Id = $"station-{++stationIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Label = plan.Label,
|
||||
Color = plan.Color,
|
||||
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
|
||||
Position = placement.Position,
|
||||
FactionId = plan.FactionId ?? DefaultFactionId,
|
||||
CelestialId = placement.AnchorCelestial.Id,
|
||||
Health = 600f,
|
||||
MaxHealth = 600f,
|
||||
};
|
||||
|
||||
stations.Add(station);
|
||||
placement.AnchorCelestial.OccupyingStructureId = station.Id;
|
||||
|
||||
var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions);
|
||||
|
||||
foreach (var moduleId in startingModules)
|
||||
{
|
||||
AddStationModule(station, moduleDefinitions, moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
return stations;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildStartingModules(
|
||||
InitialStationDefinition plan,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
var startingModules = new List<string>(plan.StartingModules.Count > 0
|
||||
? plan.StartingModules
|
||||
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]);
|
||||
|
||||
EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech");
|
||||
|
||||
var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective);
|
||||
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
|
||||
{
|
||||
EnsureStartingModule(startingModules, objectiveModuleId);
|
||||
|
||||
if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
|
||||
{
|
||||
EnsureStartingModule(startingModules, "module_gen_prod_energycells_01");
|
||||
}
|
||||
|
||||
foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions))
|
||||
{
|
||||
EnsureStartingModule(startingModules, storageModuleId);
|
||||
}
|
||||
}
|
||||
|
||||
return startingModules;
|
||||
}
|
||||
|
||||
private static string? GetObjectiveStartingModuleId(string? objective) =>
|
||||
StationSimulationService.NormalizeStationObjective(objective) switch
|
||||
{
|
||||
"power" => "module_gen_prod_energycells_01",
|
||||
"refinery" => "module_gen_ref_ore_01",
|
||||
"graphene" => "module_gen_prod_graphene_01",
|
||||
"siliconwafers" => "module_gen_prod_siliconwafers_01",
|
||||
"hullparts" => "module_gen_prod_hullparts_01",
|
||||
"claytronics" => "module_gen_prod_claytronics_01",
|
||||
"quantumtubes" => "module_gen_prod_quantumtubes_01",
|
||||
"antimattercells" => "module_gen_prod_antimattercells_01",
|
||||
"superfluidcoolant" => "module_gen_prod_superfluidcoolant_01",
|
||||
"water" => "module_gen_prod_water_01",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IEnumerable<string> GetRequiredStartingStorageModules(
|
||||
string moduleId,
|
||||
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
|
||||
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
|
||||
{
|
||||
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var wareId in moduleDefinition.Production
|
||||
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
|
||||
.Concat(moduleDefinition.Products)
|
||||
.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var storageModuleId = itemDefinition.CargoKind switch
|
||||
{
|
||||
"solid" => "module_arg_stor_solid_m_01",
|
||||
"liquid" => "module_arg_stor_liquid_m_01",
|
||||
_ => "module_arg_stor_container_m_01",
|
||||
};
|
||||
|
||||
yield return storageModuleId;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureStartingModule(List<string> modules, string moduleId)
|
||||
{
|
||||
if (!modules.Contains(moduleId, StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById)
|
||||
{
|
||||
return scenario.PatrolRoutes
|
||||
.GroupBy(route => route.SystemId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.SelectMany(route => route.Points)
|
||||
.Select(point => NormalizeScenarioPoint(systemsById[group.Key], point))
|
||||
.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static List<ShipRuntime> CreateShips(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, SystemRuntime> systemsById,
|
||||
IReadOnlyCollection<CelestialRuntime> celestials,
|
||||
BalanceDefinition balance,
|
||||
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
IReadOnlyCollection<StationRuntime> stations,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
var ships = new List<ShipRuntime>();
|
||||
var shipIdCounter = 0;
|
||||
|
||||
foreach (var formation in scenario.ShipFormations)
|
||||
{
|
||||
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var index = 0; index < formation.Count; index += 1)
|
||||
{
|
||||
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
|
||||
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
|
||||
|
||||
ships.Add(new ShipRuntime
|
||||
{
|
||||
Id = $"ship-{++shipIdCounter}",
|
||||
SystemId = formation.SystemId,
|
||||
Definition = definition,
|
||||
FactionId = formation.FactionId ?? DefaultFactionId,
|
||||
Position = position,
|
||||
TargetPosition = position,
|
||||
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
|
||||
DefaultBehavior = WorldSeedingService.CreateBehavior(
|
||||
definition,
|
||||
formation.SystemId,
|
||||
formation.FactionId ?? DefaultFactionId,
|
||||
scenario,
|
||||
patrolRoutes,
|
||||
stations,
|
||||
refinery),
|
||||
Skills = WorldSeedingService.CreateSkills(definition),
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
|
||||
foreach (var (itemId, amount) in formation.StartingInventory)
|
||||
{
|
||||
if (amount > 0f)
|
||||
{
|
||||
ships[^1].Inventory[itemId] = amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ships;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,5 +2,5 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class OrbitalSimulationOptions
|
||||
{
|
||||
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
|
||||
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
|
||||
try
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
worldService.Tick(0.2f);
|
||||
}
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
worldService.Tick(0.2f);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,41 +4,41 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class TelemetryService : IDisposable
|
||||
{
|
||||
private readonly Process _process = Process.GetCurrentProcess();
|
||||
private readonly Timer _timer;
|
||||
private double _cpuPercent;
|
||||
private DateTime _lastSampleTime;
|
||||
private TimeSpan _lastCpuTime;
|
||||
private readonly Process _process = Process.GetCurrentProcess();
|
||||
private readonly Timer _timer;
|
||||
private double _cpuPercent;
|
||||
private DateTime _lastSampleTime;
|
||||
private TimeSpan _lastCpuTime;
|
||||
|
||||
public TelemetryService()
|
||||
{
|
||||
_process.Refresh();
|
||||
_lastSampleTime = DateTime.UtcNow;
|
||||
_lastCpuTime = _process.TotalProcessorTime;
|
||||
_timer = new Timer(Sample, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
public TelemetryService()
|
||||
{
|
||||
_process.Refresh();
|
||||
_lastSampleTime = DateTime.UtcNow;
|
||||
_lastCpuTime = _process.TotalProcessorTime;
|
||||
_timer = new Timer(Sample, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private void Sample(object? _)
|
||||
{
|
||||
_process.Refresh();
|
||||
var now = DateTime.UtcNow;
|
||||
var cpu = _process.TotalProcessorTime;
|
||||
var elapsed = (now - _lastSampleTime).TotalSeconds;
|
||||
var cpuUsed = (cpu - _lastCpuTime).TotalSeconds;
|
||||
Volatile.Write(ref _cpuPercent, elapsed > 0 ? cpuUsed / elapsed / Environment.ProcessorCount * 100.0 : 0);
|
||||
_lastSampleTime = now;
|
||||
_lastCpuTime = cpu;
|
||||
}
|
||||
private void Sample(object? _)
|
||||
{
|
||||
_process.Refresh();
|
||||
var now = DateTime.UtcNow;
|
||||
var cpu = _process.TotalProcessorTime;
|
||||
var elapsed = (now - _lastSampleTime).TotalSeconds;
|
||||
var cpuUsed = (cpu - _lastCpuTime).TotalSeconds;
|
||||
Volatile.Write(ref _cpuPercent, elapsed > 0 ? cpuUsed / elapsed / Environment.ProcessorCount * 100.0 : 0);
|
||||
_lastSampleTime = now;
|
||||
_lastCpuTime = cpu;
|
||||
}
|
||||
|
||||
public double CpuPercent => Volatile.Read(ref _cpuPercent);
|
||||
public long WorkingSetBytes => _process.WorkingSet64;
|
||||
public long GcMemoryBytes => GC.GetTotalMemory(false);
|
||||
public int ThreadCount => _process.Threads.Count;
|
||||
public TimeSpan Uptime => DateTime.UtcNow - _process.StartTime.ToUniversalTime();
|
||||
public double CpuPercent => Volatile.Read(ref _cpuPercent);
|
||||
public long WorkingSetBytes => _process.WorkingSet64;
|
||||
public long GcMemoryBytes => GC.GetTotalMemory(false);
|
||||
public int ThreadCount => _process.Threads.Count;
|
||||
public TimeSpan Uptime => DateTime.UtcNow - _process.StartTime.ToUniversalTime();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_process.Dispose();
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Dispose();
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
public int TargetSystemCount { get; init; }
|
||||
public int AiControllerFactionCount { get; init; }
|
||||
public bool GeneratePlayerFaction { get; init; }
|
||||
public int TargetSystemCount { get; init; }
|
||||
public int AiControllerFactionCount { get; init; }
|
||||
public bool GeneratePlayerFaction { get; init; }
|
||||
}
|
||||
|
||||
@@ -8,507 +8,507 @@ public sealed class WorldService(
|
||||
IOptions<WorldGenerationOptions> worldGenerationOptions,
|
||||
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
|
||||
{
|
||||
private const int DeltaHistoryLimit = 256;
|
||||
private const int DeltaHistoryLimit = 256;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
|
||||
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
|
||||
private readonly PlayerFactionService _playerFaction = new();
|
||||
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
|
||||
private readonly Queue<WorldDelta> _history = [];
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
|
||||
private long _sequence;
|
||||
private BalanceDefinition? _balanceOverride;
|
||||
private readonly Lock _sync = new();
|
||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
|
||||
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
|
||||
private readonly PlayerFactionService _playerFaction = new();
|
||||
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
|
||||
private readonly Queue<WorldDelta> _history = [];
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
|
||||
private long _sequence;
|
||||
private BalanceDefinition? _balanceOverride;
|
||||
|
||||
public WorldSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
public WorldSnapshot GetSnapshot()
|
||||
{
|
||||
return _engine.BuildSnapshot(_world, _sequence);
|
||||
}
|
||||
}
|
||||
|
||||
public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return (_sequence, _world.GeneratedAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return (_subscribers.Count, _history.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public BalanceDefinition GetBalance()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var b = _world.Balance;
|
||||
return new BalanceDefinition
|
||||
{
|
||||
SimulationSpeedMultiplier = b.SimulationSpeedMultiplier,
|
||||
YPlane = b.YPlane,
|
||||
ArrivalThreshold = b.ArrivalThreshold,
|
||||
MiningRate = b.MiningRate,
|
||||
MiningCycleSeconds = b.MiningCycleSeconds,
|
||||
TransferRate = b.TransferRate,
|
||||
DockingDuration = b.DockingDuration,
|
||||
UndockingDuration = b.UndockingDuration,
|
||||
UndockDistance = b.UndockDistance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public BalanceDefinition UpdateBalance(BalanceDefinition balance)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_balanceOverride = SanitizeBalance(balance);
|
||||
ApplyBalance(_world, _balanceOverride);
|
||||
return GetBalance();
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? RemoveShipOrder(string shipId, string orderId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? GetPlayerFaction()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.EnsureDomain(_world);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.CreateOrganization(_world, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.DeleteOrganization(_world, organizationId);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertDirective(_world, directiveId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.DeleteDirective(_world, directiveId);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertPolicy(_world, policyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertAssignment(_world, assetId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpdateStrategicIntent(_world, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
Guid subscriberId;
|
||||
lock (_sync)
|
||||
{
|
||||
subscriberId = Guid.NewGuid();
|
||||
_subscribers.Add(subscriberId, new SubscriptionState(scope, channel));
|
||||
|
||||
foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence))
|
||||
{
|
||||
var filtered = FilterDeltaForScope(delta, scope);
|
||||
if (HasMeaningfulDelta(filtered))
|
||||
lock (_sync)
|
||||
{
|
||||
channel.Writer.TryWrite(filtered);
|
||||
return _engine.BuildSnapshot(_world, _sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.Register(() => Unsubscribe(subscriberId));
|
||||
return channel.Reader;
|
||||
}
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
WorldDelta? delta = null;
|
||||
lock (_sync)
|
||||
public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus()
|
||||
{
|
||||
delta = _engine.Tick(_world, deltaSeconds, ++_sequence);
|
||||
if (!HasMeaningfulDelta(delta))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_history.Enqueue(delta);
|
||||
while (_history.Count > DeltaHistoryLimit)
|
||||
{
|
||||
_history.Dequeue();
|
||||
}
|
||||
|
||||
foreach (var subscriber in _subscribers.Values.ToList())
|
||||
{
|
||||
var filtered = FilterDeltaForScope(delta, subscriber.Scope);
|
||||
if (HasMeaningfulDelta(filtered))
|
||||
lock (_sync)
|
||||
{
|
||||
subscriber.Channel.Writer.TryWrite(filtered);
|
||||
return (_sequence, _world.GeneratedAtUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WorldSnapshot Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats()
|
||||
{
|
||||
_world = _loader.Load();
|
||||
if (_balanceOverride is not null)
|
||||
{
|
||||
ApplyBalance(_world, _balanceOverride);
|
||||
}
|
||||
_sequence += 1;
|
||||
_history.Clear();
|
||||
|
||||
var resetDelta = new WorldDelta(
|
||||
_sequence,
|
||||
_world.TickIntervalMs,
|
||||
_world.OrbitalTimeSeconds,
|
||||
_orbitalSimulation,
|
||||
DateTimeOffset.UtcNow,
|
||||
true,
|
||||
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null);
|
||||
|
||||
_history.Enqueue(resetDelta);
|
||||
foreach (var subscriber in _subscribers.Values.ToList())
|
||||
{
|
||||
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope));
|
||||
}
|
||||
|
||||
return _engine.BuildSnapshot(_world, _sequence);
|
||||
lock (_sync)
|
||||
{
|
||||
return (_subscribers.Count, _history.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) =>
|
||||
world.Balance = new BalanceDefinition
|
||||
public BalanceDefinition GetBalance()
|
||||
{
|
||||
SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier,
|
||||
YPlane = balance.YPlane,
|
||||
ArrivalThreshold = balance.ArrivalThreshold,
|
||||
MiningRate = balance.MiningRate,
|
||||
MiningCycleSeconds = balance.MiningCycleSeconds,
|
||||
TransferRate = balance.TransferRate,
|
||||
DockingDuration = balance.DockingDuration,
|
||||
UndockingDuration = balance.UndockingDuration,
|
||||
UndockDistance = balance.UndockDistance,
|
||||
};
|
||||
|
||||
private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate)
|
||||
{
|
||||
static float finiteOr(float value, float fallback) =>
|
||||
float.IsFinite(value) ? value : fallback;
|
||||
|
||||
return new BalanceDefinition
|
||||
{
|
||||
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
|
||||
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),
|
||||
ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)),
|
||||
MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)),
|
||||
MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)),
|
||||
TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)),
|
||||
DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)),
|
||||
UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)),
|
||||
UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)),
|
||||
};
|
||||
}
|
||||
|
||||
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
|
||||
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
|
||||
|
||||
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
||||
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
|
||||
|
||||
private static bool HasMeaningfulDelta(WorldDelta delta) =>
|
||||
delta.RequiresSnapshotRefresh
|
||||
|| delta.Events.Count > 0
|
||||
|| delta.Celestials.Count > 0
|
||||
|| delta.Nodes.Count > 0
|
||||
|| delta.Stations.Count > 0
|
||||
|| delta.Claims.Count > 0
|
||||
|| delta.ConstructionSites.Count > 0
|
||||
|| delta.MarketOrders.Count > 0
|
||||
|| delta.Policies.Count > 0
|
||||
|| delta.Ships.Count > 0
|
||||
|| delta.Factions.Count > 0
|
||||
|| delta.PlayerFaction is not null
|
||||
|| delta.Geopolitics is not null;
|
||||
|
||||
private void Unsubscribe(Guid subscriberId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_subscribers.Remove(subscriberId, out var subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.Channel.Writer.TryComplete();
|
||||
lock (_sync)
|
||||
{
|
||||
var b = _world.Balance;
|
||||
return new BalanceDefinition
|
||||
{
|
||||
SimulationSpeedMultiplier = b.SimulationSpeedMultiplier,
|
||||
YPlane = b.YPlane,
|
||||
ArrivalThreshold = b.ArrivalThreshold,
|
||||
MiningRate = b.MiningRate,
|
||||
MiningCycleSeconds = b.MiningCycleSeconds,
|
||||
TransferRate = b.TransferRate,
|
||||
DockingDuration = b.DockingDuration,
|
||||
UndockingDuration = b.UndockingDuration,
|
||||
UndockDistance = b.UndockDistance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope)
|
||||
{
|
||||
if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase))
|
||||
public BalanceDefinition UpdateBalance(BalanceDefinition balance)
|
||||
{
|
||||
return delta with
|
||||
lock (_sync)
|
||||
{
|
||||
_balanceOverride = SanitizeBalance(balance);
|
||||
ApplyBalance(_world, _balanceOverride);
|
||||
return GetBalance();
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? RemoveShipOrder(string shipId, string orderId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? GetPlayerFaction()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.EnsureDomain(_world);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.CreateOrganization(_world, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.DeleteOrganization(_world, organizationId);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertDirective(_world, directiveId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.DeleteDirective(_world, directiveId);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertPolicy(_world, policyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpsertAssignment(_world, assetId, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_playerFaction.UpdateStrategicIntent(_world, request);
|
||||
return GetPlayerFactionSnapshotUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
Guid subscriberId;
|
||||
lock (_sync)
|
||||
{
|
||||
subscriberId = Guid.NewGuid();
|
||||
_subscribers.Add(subscriberId, new SubscriptionState(scope, channel));
|
||||
|
||||
foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence))
|
||||
{
|
||||
var filtered = FilterDeltaForScope(delta, scope);
|
||||
if (HasMeaningfulDelta(filtered))
|
||||
{
|
||||
channel.Writer.TryWrite(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.Register(() => Unsubscribe(subscriberId));
|
||||
return channel.Reader;
|
||||
}
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
WorldDelta? delta = null;
|
||||
lock (_sync)
|
||||
{
|
||||
delta = _engine.Tick(_world, deltaSeconds, ++_sequence);
|
||||
if (!HasMeaningfulDelta(delta))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_history.Enqueue(delta);
|
||||
while (_history.Count > DeltaHistoryLimit)
|
||||
{
|
||||
_history.Dequeue();
|
||||
}
|
||||
|
||||
foreach (var subscriber in _subscribers.Values.ToList())
|
||||
{
|
||||
var filtered = FilterDeltaForScope(delta, subscriber.Scope);
|
||||
if (HasMeaningfulDelta(filtered))
|
||||
{
|
||||
subscriber.Channel.Writer.TryWrite(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WorldSnapshot Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_world = _loader.Load();
|
||||
if (_balanceOverride is not null)
|
||||
{
|
||||
ApplyBalance(_world, _balanceOverride);
|
||||
}
|
||||
_sequence += 1;
|
||||
_history.Clear();
|
||||
|
||||
var resetDelta = new WorldDelta(
|
||||
_sequence,
|
||||
_world.TickIntervalMs,
|
||||
_world.OrbitalTimeSeconds,
|
||||
_orbitalSimulation,
|
||||
DateTimeOffset.UtcNow,
|
||||
true,
|
||||
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null);
|
||||
|
||||
_history.Enqueue(resetDelta);
|
||||
foreach (var subscriber in _subscribers.Values.ToList())
|
||||
{
|
||||
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope));
|
||||
}
|
||||
|
||||
return _engine.BuildSnapshot(_world, _sequence);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) =>
|
||||
world.Balance = new BalanceDefinition
|
||||
{
|
||||
Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(),
|
||||
Scope = scope,
|
||||
SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier,
|
||||
YPlane = balance.YPlane,
|
||||
ArrivalThreshold = balance.ArrivalThreshold,
|
||||
MiningRate = balance.MiningRate,
|
||||
MiningCycleSeconds = balance.MiningCycleSeconds,
|
||||
TransferRate = balance.TransferRate,
|
||||
DockingDuration = balance.DockingDuration,
|
||||
UndockingDuration = balance.UndockingDuration,
|
||||
UndockDistance = balance.UndockDistance,
|
||||
};
|
||||
|
||||
private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate)
|
||||
{
|
||||
static float finiteOr(float value, float fallback) =>
|
||||
float.IsFinite(value) ? value : fallback;
|
||||
|
||||
return new BalanceDefinition
|
||||
{
|
||||
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
|
||||
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),
|
||||
ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)),
|
||||
MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)),
|
||||
MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)),
|
||||
TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)),
|
||||
DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)),
|
||||
UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)),
|
||||
UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)),
|
||||
};
|
||||
}
|
||||
|
||||
var systemFilter = scope.SystemId;
|
||||
if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null)
|
||||
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
|
||||
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
|
||||
|
||||
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
||||
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
|
||||
|
||||
private static bool HasMeaningfulDelta(WorldDelta delta) =>
|
||||
delta.RequiresSnapshotRefresh
|
||||
|| delta.Events.Count > 0
|
||||
|| delta.Celestials.Count > 0
|
||||
|| delta.Nodes.Count > 0
|
||||
|| delta.Stations.Count > 0
|
||||
|| delta.Claims.Count > 0
|
||||
|| delta.ConstructionSites.Count > 0
|
||||
|| delta.MarketOrders.Count > 0
|
||||
|| delta.Policies.Count > 0
|
||||
|| delta.Ships.Count > 0
|
||||
|| delta.Factions.Count > 0
|
||||
|| delta.PlayerFaction is not null
|
||||
|| delta.Geopolitics is not null;
|
||||
|
||||
private void Unsubscribe(Guid subscriberId)
|
||||
{
|
||||
systemFilter = ResolveCelestialSystemId(scope.CelestialId);
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_subscribers.Remove(subscriberId, out var subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.Channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
return delta with
|
||||
private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope)
|
||||
{
|
||||
Events = delta.Events
|
||||
.Select((evt) => EnrichEventScope(evt))
|
||||
.Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter))
|
||||
.ToList(),
|
||||
Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(),
|
||||
Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(),
|
||||
Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(),
|
||||
Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(),
|
||||
ConstructionSites = delta.ConstructionSites.Where((site) => systemFilter is null || site.SystemId == systemFilter).ToList(),
|
||||
MarketOrders = delta.MarketOrders.Where((order) => IsOrderVisibleToScope(order, systemFilter)).ToList(),
|
||||
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
|
||||
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
|
||||
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
|
||||
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
|
||||
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
|
||||
Scope = scope,
|
||||
};
|
||||
}
|
||||
if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return delta with
|
||||
{
|
||||
Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(),
|
||||
Scope = scope,
|
||||
};
|
||||
}
|
||||
|
||||
private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt)
|
||||
{
|
||||
if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null)
|
||||
{
|
||||
return evt;
|
||||
var systemFilter = scope.SystemId;
|
||||
if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null)
|
||||
{
|
||||
systemFilter = ResolveCelestialSystemId(scope.CelestialId);
|
||||
}
|
||||
|
||||
return delta with
|
||||
{
|
||||
Events = delta.Events
|
||||
.Select((evt) => EnrichEventScope(evt))
|
||||
.Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter))
|
||||
.ToList(),
|
||||
Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(),
|
||||
Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(),
|
||||
Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(),
|
||||
Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(),
|
||||
ConstructionSites = delta.ConstructionSites.Where((site) => systemFilter is null || site.SystemId == systemFilter).ToList(),
|
||||
MarketOrders = delta.MarketOrders.Where((order) => IsOrderVisibleToScope(order, systemFilter)).ToList(),
|
||||
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
|
||||
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
|
||||
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
|
||||
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
|
||||
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
|
||||
Scope = scope,
|
||||
};
|
||||
}
|
||||
|
||||
return evt.EntityKind switch
|
||||
private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt)
|
||||
{
|
||||
"ship" => WithEntityScope(evt, "system", _world.Ships.FirstOrDefault((ship) => ship.Id == evt.EntityId)?.SystemId),
|
||||
"station" => WithEntityScope(evt, "system", _world.Stations.FirstOrDefault((station) => station.Id == evt.EntityId)?.SystemId),
|
||||
"node" => WithEntityScope(evt, "system", _world.Nodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId),
|
||||
"celestial" => WithEntityScope(evt, "system", _world.Celestials.FirstOrDefault((c) => c.Id == evt.EntityId)?.SystemId),
|
||||
"claim" => WithEntityScope(evt, "system", _world.Claims.FirstOrDefault((claim) => claim.Id == evt.EntityId)?.SystemId),
|
||||
"construction-site" => WithEntityScope(evt, "system", _world.ConstructionSites.FirstOrDefault((site) => site.Id == evt.EntityId)?.SystemId),
|
||||
"market-order" => WithEntityScope(evt, "system", ResolveMarketOrderSystemId(evt.EntityId)),
|
||||
_ => evt,
|
||||
};
|
||||
}
|
||||
if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null)
|
||||
{
|
||||
return evt;
|
||||
}
|
||||
|
||||
private static SimulationEventRecord WithEntityScope(SimulationEventRecord evt, string scopeKind, string? scopeEntityId) =>
|
||||
evt with
|
||||
{
|
||||
Family = evt.Kind.Contains("power", StringComparison.Ordinal) ? "power" :
|
||||
evt.Kind.Contains("construction", StringComparison.Ordinal) ? "construction" :
|
||||
evt.Kind.Contains("population", StringComparison.Ordinal) ? "population" :
|
||||
evt.Kind.Contains("claim", StringComparison.Ordinal) ? "claim" :
|
||||
"simulation",
|
||||
ScopeKind = scopeKind,
|
||||
ScopeEntityId = scopeEntityId,
|
||||
};
|
||||
|
||||
private string? ResolveCelestialSystemId(string celestialId) =>
|
||||
_world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId;
|
||||
|
||||
private string? ResolveMarketOrderSystemId(string orderId)
|
||||
{
|
||||
var order = _world.MarketOrders.FirstOrDefault((candidate) => candidate.Id == orderId);
|
||||
if (order?.StationId is not null)
|
||||
{
|
||||
return _world.Stations.FirstOrDefault((station) => station.Id == order.StationId)?.SystemId;
|
||||
return evt.EntityKind switch
|
||||
{
|
||||
"ship" => WithEntityScope(evt, "system", _world.Ships.FirstOrDefault((ship) => ship.Id == evt.EntityId)?.SystemId),
|
||||
"station" => WithEntityScope(evt, "system", _world.Stations.FirstOrDefault((station) => station.Id == evt.EntityId)?.SystemId),
|
||||
"node" => WithEntityScope(evt, "system", _world.Nodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId),
|
||||
"celestial" => WithEntityScope(evt, "system", _world.Celestials.FirstOrDefault((c) => c.Id == evt.EntityId)?.SystemId),
|
||||
"claim" => WithEntityScope(evt, "system", _world.Claims.FirstOrDefault((claim) => claim.Id == evt.EntityId)?.SystemId),
|
||||
"construction-site" => WithEntityScope(evt, "system", _world.ConstructionSites.FirstOrDefault((site) => site.Id == evt.EntityId)?.SystemId),
|
||||
"market-order" => WithEntityScope(evt, "system", ResolveMarketOrderSystemId(evt.EntityId)),
|
||||
_ => evt,
|
||||
};
|
||||
}
|
||||
|
||||
if (order?.ConstructionSiteId is not null)
|
||||
private static SimulationEventRecord WithEntityScope(SimulationEventRecord evt, string scopeKind, string? scopeEntityId) =>
|
||||
evt with
|
||||
{
|
||||
Family = evt.Kind.Contains("power", StringComparison.Ordinal) ? "power" :
|
||||
evt.Kind.Contains("construction", StringComparison.Ordinal) ? "construction" :
|
||||
evt.Kind.Contains("population", StringComparison.Ordinal) ? "population" :
|
||||
evt.Kind.Contains("claim", StringComparison.Ordinal) ? "claim" :
|
||||
"simulation",
|
||||
ScopeKind = scopeKind,
|
||||
ScopeEntityId = scopeEntityId,
|
||||
};
|
||||
|
||||
private string? ResolveCelestialSystemId(string celestialId) =>
|
||||
_world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId;
|
||||
|
||||
private string? ResolveMarketOrderSystemId(string orderId)
|
||||
{
|
||||
return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId;
|
||||
var order = _world.MarketOrders.FirstOrDefault((candidate) => candidate.Id == orderId);
|
||||
if (order?.StationId is not null)
|
||||
{
|
||||
return _world.Stations.FirstOrDefault((station) => station.Id == order.StationId)?.SystemId;
|
||||
}
|
||||
|
||||
if (order?.ConstructionSiteId is not null)
|
||||
{
|
||||
return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter)
|
||||
{
|
||||
if (systemFilter is null)
|
||||
private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter)
|
||||
{
|
||||
return true;
|
||||
if (systemFilter is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (order.StationId is not null)
|
||||
{
|
||||
return _world.Stations.Any((station) => station.Id == order.StationId && station.SystemId == systemFilter);
|
||||
}
|
||||
|
||||
if (order.ConstructionSiteId is not null)
|
||||
{
|
||||
return _world.ConstructionSites.Any((site) => site.Id == order.ConstructionSiteId && site.SystemId == systemFilter);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.StationId is not null)
|
||||
private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter)
|
||||
{
|
||||
return _world.Stations.Any((station) => station.Id == order.StationId && station.SystemId == systemFilter);
|
||||
return scope.ScopeKind switch
|
||||
{
|
||||
"universe" => true,
|
||||
"system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
||||
"local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
if (order.ConstructionSiteId is not null)
|
||||
{
|
||||
return _world.ConstructionSites.Any((site) => site.Id == order.ConstructionSiteId && site.SystemId == systemFilter);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter)
|
||||
{
|
||||
return scope.ScopeKind switch
|
||||
{
|
||||
"universe" => true,
|
||||
"system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
||||
"local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(ObserverScope Scope, Channel<WorldDelta> Channel);
|
||||
private sealed record SubscriptionState(ObserverScope Scope, Channel<WorldDelta> Channel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user