improvement on gm windows, ai

This commit is contained in:
2026-03-20 12:40:26 -04:00
parent ff078fe939
commit 3b56785f9a
39 changed files with 2594 additions and 358 deletions

View File

@@ -0,0 +1,16 @@
using FastEndpoints;
using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/balance");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.GetBalance(), cancellationToken);
}

View File

@@ -0,0 +1,49 @@
using System.Runtime.InteropServices;
using FastEndpoints;
using SpaceGame.Api.Universe.Simulation;
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
{
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);
}
}

View File

@@ -0,0 +1,20 @@
using FastEndpoints;
using SpaceGame.Api.Definitions;
using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api;
public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<BalanceDefinition>
{
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);
}
}

View File

@@ -5,7 +5,7 @@ public sealed class SimulationWorld
{
public required string Label { get; init; }
public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; }
public required BalanceDefinition Balance { get; set; }
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; }

View File

@@ -224,6 +224,23 @@ internal sealed class SpatialBuilder
private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
{
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,
};
if (anchorId is not null)
{
return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal));
}
}
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
{
return null;

View File

@@ -9,7 +9,7 @@ internal sealed class SystemGenerationService
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems
.Select(CloneSystemDefinition)
.Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList();
internal List<SolarSystemDefinition> ExpandSystems(
@@ -126,6 +126,7 @@ internal sealed class SystemGenerationService
.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
@@ -137,7 +138,7 @@ internal sealed class SystemGenerationService
})
.ToList();
return new SolarSystemDefinition
return EnsureStrategicResourceCoverage(new SolarSystemDefinition
{
Id = id,
Label = label,
@@ -161,7 +162,7 @@ internal sealed class SystemGenerationService
},
ResourceNodes = resourceNodes,
Planets = planets,
};
}, generatedIndex + 1024);
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
@@ -182,6 +183,7 @@ internal sealed class SystemGenerationService
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
@@ -223,6 +225,7 @@ internal sealed class SystemGenerationService
nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
@@ -234,10 +237,30 @@ internal sealed class SystemGenerationService
}));
}
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
return nodes;
}
private static SolarSystemDefinition EnsureStrategicResourceCoverage(SolarSystemDefinition system, int seed)
{
for (var index = 0; index < system.ResourceNodes.Count; index += 1)
{
system.ResourceNodes[index] = SanitizeResourceNode(system.ResourceNodes[index], system.Planets, seed, index);
}
var requiredItems = new[] { "ore", "silicon", "ice", "hydrogen", "helium", "methane" };
foreach (var itemId in requiredItems)
{
if (system.ResourceNodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal)))
{
continue;
}
system.ResourceNodes.Add(BuildStrategicResourceNode(itemId, system.Planets, seed, system.ResourceNodes.Count));
}
return system;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{
var allPositions = occupiedPositions.ToList();
@@ -303,25 +326,124 @@ internal sealed class SystemGenerationService
return $"gen-{ordinal}-{slug}";
}
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
private static ResourceNodeDefinition BuildStrategicResourceNode(
string itemId,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1)
var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets);
return new ResourceNodeDefinition
{
yield return new ResourceNodeDefinition
SourceKind = "local-space",
AnchorReference = ResolveStrategicAnchorReference(itemId, planets, ordinal),
Angle = (MathF.PI * 2f * ((ordinal % 7) / 7f)) + Jitter(seed, 400 + ordinal, 0.35f),
RadiusOffset = 150000f + Jitter(seed, 460 + ordinal, 42000f),
InclinationDegrees = Jitter(seed, 520 + ordinal, 10f),
AnchorPlanetIndex = anchorPlanetIndex,
OreAmount = itemId switch
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = 120000f + Jitter(generatedIndex, 200 + index, 36000f),
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
OreAmount = oreAmount,
ItemId = "ore",
ShardCount = 6 + (index % 4),
};
"ore" => 12000f,
"silicon" => 10000f,
"ice" => 9000f,
_ => 8000f,
},
ItemId = itemId,
ShardCount = itemId switch
{
"ore" or "silicon" or "ice" => 8,
_ => 6,
},
};
}
private static ResourceNodeDefinition SanitizeResourceNode(
ResourceNodeDefinition node,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
node.SourceKind = "local-space";
node.AnchorReference ??= ResolveLegacyAnchorReference(node, planets, seed, ordinal);
return node;
}
private static string ResolveLegacyAnchorReference(
ResourceNodeDefinition node,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
if (node.AnchorMoonIndex is int moonIndex && node.AnchorPlanetIndex is int planetIndex && planetIndex >= 0)
{
return $"planet-{planetIndex + 1}-moon-{moonIndex + 1}";
}
if (node.AnchorPlanetIndex is int anchoredPlanetIndex && anchoredPlanetIndex >= 0)
{
return $"planet-{anchoredPlanetIndex + 1}";
}
return ResolveStrategicAnchorReference(node.ItemId, planets, ordinal + seed);
}
private static string ResolveStrategicAnchorReference(string itemId, IReadOnlyList<PlanetDefinition> planets, int ordinal)
{
if (itemId is "hydrogen" or "helium" or "methane")
{
var gasGiantIndex = planets
.Select((planet, index) => (planet, index))
.FirstOrDefault(entry => entry.planet.PlanetType is "gas-giant" or "ice-giant")
.index;
return gasGiantIndex > 0 || (planets.Count > 0 && planets[0].PlanetType is "gas-giant" or "ice-giant")
? $"planet-{gasGiantIndex + 1}"
: "star-1";
}
if (itemId == "ice")
{
var moonAnchor = planets
.Select((planet, index) => (planet, index))
.FirstOrDefault(entry => entry.planet.Moons.Count > 0 && entry.planet.PlanetType is "ice" or "ice-giant" or "oceanic");
if (moonAnchor.planet is not null && moonAnchor.planet.Moons.Count > 0)
{
return $"planet-{moonAnchor.index + 1}-moon-1";
}
}
var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets);
var lagrange = (ordinal % 3) switch
{
0 => "l1",
1 => "l4",
_ => "l5",
};
return $"planet-{anchorPlanetIndex + 1}-{lagrange}";
}
private static int ResolveStrategicResourceAnchorPlanetIndex(string itemId, IReadOnlyList<PlanetDefinition> planets)
{
if (planets.Count == 0)
{
return 0;
}
bool MatchesPlanetType(PlanetDefinition planet) => itemId switch
{
"hydrogen" or "helium" or "methane" => planet.PlanetType is "gas-giant" or "ice-giant",
"ice" => planet.PlanetType is "ice" or "ice-giant" or "oceanic",
_ => planet.PlanetType is not "gas-giant" and not "ice-giant",
};
for (var index = 0; index < planets.Count; index += 1)
{
if (MatchesPlanetType(planets[index]))
{
return index;
}
}
return ResolveAsteroidAnchorPlanetIndex(planets);
}
private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)

View File

@@ -34,7 +34,8 @@ internal sealed class WorldBuilder(
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
catalog.ModuleDefinitions);
catalog.ModuleDefinitions,
catalog.ItemDefinitions);
seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
@@ -106,7 +107,8 @@ internal sealed class WorldBuilder(
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions)
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
@@ -136,9 +138,7 @@ internal sealed class WorldBuilder(
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions);
foreach (var moduleId in startingModules)
{
@@ -149,6 +149,91 @@ internal sealed class WorldBuilder(
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)

View File

@@ -65,33 +65,6 @@ internal sealed class WorldSeedingService
foreach (var station in stations)
{
InitializeStationPopulation(station);
if (station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal))
{
station.Inventory["energycells"] = MathF.Max(GetInventoryAmount(station.Inventory, "energycells"), 240f);
}
if (station.InstalledModules.Contains("module_gen_prod_refinedmetals_01", StringComparer.Ordinal))
{
station.Inventory["ore"] = MathF.Max(GetInventoryAmount(station.Inventory, "ore"), 220f);
}
if (station.InstalledModules.Contains("module_gen_prod_hullparts_01", StringComparer.Ordinal))
{
station.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(station.Inventory, "refinedmetals"), 240f);
station.Inventory["graphene"] = MathF.Max(GetInventoryAmount(station.Inventory, "graphene"), 80f);
}
if (station.InstalledModules.Contains("module_gen_prod_claytronics_01", StringComparer.Ordinal))
{
station.Inventory["antimattercells"] = MathF.Max(GetInventoryAmount(station.Inventory, "antimattercells"), 90f);
station.Inventory["microchips"] = MathF.Max(GetInventoryAmount(station.Inventory, "microchips"), 120f);
station.Inventory["quantumtubes"] = MathF.Max(GetInventoryAmount(station.Inventory, "quantumtubes"), 90f);
}
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f);
}
}
}
@@ -145,6 +118,11 @@ internal sealed class WorldSeedingService
foreach (var station in world.Stations)
{
if (HasSatisfiedStarterObjectiveLayout(world, station))
{
continue;
}
var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world);
if (moduleId is null || station.CelestialId is null)
{
@@ -200,6 +178,78 @@ internal sealed class WorldSeedingService
return (sites, orders);
}
private static bool HasSatisfiedStarterObjectiveLayout(SimulationWorld world, StationRuntime station)
{
var role = StationSimulationService.DetermineStationRole(station);
var objectiveModuleId = role switch
{
"power" => "module_gen_prod_energycells_01",
"refinery" => "module_gen_prod_refinedmetals_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,
};
if (objectiveModuleId is null)
{
return false;
}
if (!station.InstalledModules.Contains("module_arg_dock_m_01_lowtech", StringComparer.Ordinal)
|| !station.InstalledModules.Contains(objectiveModuleId, StringComparer.Ordinal))
{
return false;
}
if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)
&& !station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal))
{
return false;
}
foreach (var storageModuleId in GetRequiredStorageModulesForInstalledObjective(world, objectiveModuleId))
{
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
{
return false;
}
}
return true;
}
private static IEnumerable<string> GetRequiredStorageModulesForInstalledObjective(SimulationWorld world, string moduleId)
{
if (!world.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 (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition))
{
continue;
}
yield return itemDefinition.CargoKind switch
{
"solid" => "module_arg_stor_solid_m_01",
"liquid" => "module_arg_stor_liquid_m_01",
_ => "module_arg_stor_container_m_01",
};
}
}
internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{
var policies = new List<PolicySetRuntime>(factions.Count);
@@ -385,6 +435,13 @@ internal sealed class WorldSeedingService
Color = "#ff8f70",
Credits = MinimumFactionCredits,
},
"nadir-syndicate" => new FactionRuntime
{
Id = factionId,
Label = "Nadir Syndicate",
Color = "#91e6a8",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,

View File

@@ -0,0 +1,44 @@
using System.Diagnostics;
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;
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;
}
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();
}
}

View File

@@ -18,6 +18,7 @@ public sealed class WorldService(
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
private long _sequence;
private BalanceDefinition? _balanceOverride;
public WorldSnapshot GetSnapshot()
{
@@ -35,6 +36,44 @@ public sealed class WorldService(
}
}
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 ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
@@ -96,6 +135,10 @@ public sealed class WorldService(
lock (_sync)
{
_world = _loader.Load();
if (_balanceOverride is not null)
{
ApplyBalance(_world, _balanceOverride);
}
_sequence += 1;
_history.Clear();
@@ -127,6 +170,39 @@ public sealed class WorldService(
}
}
private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) =>
world.Balance = new BalanceDefinition
{
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 static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh
|| delta.Events.Count > 0