feat: production chain
This commit is contained in:
@@ -20,6 +20,7 @@ internal sealed class ShipBehaviorStateMachine
|
||||
new PatrolShipBehaviorState(),
|
||||
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"),
|
||||
new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"),
|
||||
new EnergySupplyShipBehaviorState(),
|
||||
new ConstructStationShipBehaviorState(),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ internal sealed class IdleShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
|
||||
ship.DefaultBehavior.Kind = "idle";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
@@ -39,7 +39,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
|
||||
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
|
||||
TargetSystemId = ship.SystemId,
|
||||
Threshold = 18f,
|
||||
@@ -82,6 +82,10 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
|
||||
case ("extract", "cargo-full"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||
break;
|
||||
case ("extract", "node-depleted"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
break;
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
@@ -114,10 +118,7 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship) ? "refuel" : "deliver-to-site";
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "deliver-to-site";
|
||||
break;
|
||||
case ("refuel", "refueled"):
|
||||
ship.DefaultBehavior.Phase = "deliver-to-site";
|
||||
@@ -133,3 +134,37 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EnergySupplyShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "auto-supply-energy";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanEnergySupply(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-source", "arrived"):
|
||||
case ("travel-to-destination", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "load";
|
||||
break;
|
||||
case ("load", "loaded"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
|
||||
break;
|
||||
case ("refuel", "refueled"):
|
||||
ship.DefaultBehavior.Phase = "undock";
|
||||
break;
|
||||
case ("undock", "undocked"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "travel-to-destination" : "travel-to-source";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class ShipRuntime
|
||||
public required Vector3 TargetPosition { get; set; }
|
||||
public required ShipSpatialStateRuntime SpatialState { get; set; }
|
||||
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||
public string State { get; set; } = "idle";
|
||||
public ShipState State { get; set; } = ShipState.Idle;
|
||||
public ShipOrderRuntime? Order { get; set; }
|
||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||
public required ControllerTaskRuntime ControllerTask { get; set; }
|
||||
@@ -25,6 +25,8 @@ public sealed class ShipRuntime
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public float Health { get; set; }
|
||||
public string? TrackedActionKey { get; set; }
|
||||
public float TrackedActionTotal { get; set; }
|
||||
public List<string> History { get; } = [];
|
||||
public string LastSignature { get; set; } = string.Empty;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
@@ -53,7 +55,7 @@ public sealed class DefaultBehaviorRuntime
|
||||
|
||||
public sealed class ControllerTaskRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public required ControllerTaskKind Kind { get; set; }
|
||||
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
||||
public string? CommanderId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
|
||||
@@ -24,6 +24,53 @@ public enum OrderStatus
|
||||
Completed,
|
||||
}
|
||||
|
||||
public enum ShipState
|
||||
{
|
||||
Idle,
|
||||
Arriving,
|
||||
CapacitorStarved,
|
||||
LocalFlight,
|
||||
SpoolingWarp,
|
||||
Warping,
|
||||
SpoolingFtl,
|
||||
Ftl,
|
||||
CargoFull,
|
||||
MiningApproach,
|
||||
Mining,
|
||||
NodeDepleted,
|
||||
AwaitingDock,
|
||||
DockingApproach,
|
||||
Docking,
|
||||
Docked,
|
||||
Transferring,
|
||||
Loading,
|
||||
Unloading,
|
||||
Refueling,
|
||||
WaitingMaterials,
|
||||
ConstructionBlocked,
|
||||
Constructing,
|
||||
DeliveringConstruction,
|
||||
Blocked,
|
||||
Undocking,
|
||||
}
|
||||
|
||||
public enum ControllerTaskKind
|
||||
{
|
||||
Idle,
|
||||
Travel,
|
||||
Extract,
|
||||
Dock,
|
||||
Load,
|
||||
Unload,
|
||||
Refuel,
|
||||
DeliverConstruction,
|
||||
BuildConstructionSite,
|
||||
LoadWorkers,
|
||||
UnloadWorkers,
|
||||
ConstructModule,
|
||||
Undock,
|
||||
}
|
||||
|
||||
public static class SpaceLayerKinds
|
||||
{
|
||||
public const string UniverseSpace = "universe-space";
|
||||
@@ -145,4 +192,53 @@ public static class SimulationEnumMappings
|
||||
OrderStatus.Completed => "completed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipState state) => state switch
|
||||
{
|
||||
ShipState.Idle => "idle",
|
||||
ShipState.Arriving => "arriving",
|
||||
ShipState.CapacitorStarved => "capacitor-starved",
|
||||
ShipState.LocalFlight => "local-flight",
|
||||
ShipState.SpoolingWarp => "spooling-warp",
|
||||
ShipState.Warping => "warping",
|
||||
ShipState.SpoolingFtl => "spooling-ftl",
|
||||
ShipState.Ftl => "ftl",
|
||||
ShipState.CargoFull => "cargo-full",
|
||||
ShipState.MiningApproach => "mining-approach",
|
||||
ShipState.Mining => "mining",
|
||||
ShipState.NodeDepleted => "node-depleted",
|
||||
ShipState.AwaitingDock => "awaiting-dock",
|
||||
ShipState.DockingApproach => "docking-approach",
|
||||
ShipState.Docking => "docking",
|
||||
ShipState.Docked => "docked",
|
||||
ShipState.Transferring => "transferring",
|
||||
ShipState.Loading => "loading",
|
||||
ShipState.Unloading => "unloading",
|
||||
ShipState.Refueling => "refueling",
|
||||
ShipState.WaitingMaterials => "waiting-materials",
|
||||
ShipState.ConstructionBlocked => "construction-blocked",
|
||||
ShipState.Constructing => "constructing",
|
||||
ShipState.DeliveringConstruction => "delivering-construction",
|
||||
ShipState.Blocked => "blocked",
|
||||
ShipState.Undocking => "undocking",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ControllerTaskKind kind) => kind switch
|
||||
{
|
||||
ControllerTaskKind.Idle => "idle",
|
||||
ControllerTaskKind.Travel => "travel",
|
||||
ControllerTaskKind.Extract => "extract",
|
||||
ControllerTaskKind.Dock => "dock",
|
||||
ControllerTaskKind.Load => "load",
|
||||
ControllerTaskKind.Unload => "unload",
|
||||
ControllerTaskKind.Refuel => "refuel",
|
||||
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
||||
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
||||
ControllerTaskKind.LoadWorkers => "load-workers",
|
||||
ControllerTaskKind.UnloadWorkers => "unload-workers",
|
||||
ControllerTaskKind.ConstructModule => "construct-module",
|
||||
ControllerTaskKind.Undock => "undock",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ public sealed class SimulationWorld
|
||||
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
|
||||
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
|
||||
public int TickIntervalMs { get; init; } = 200;
|
||||
public double OrbitalTimeSeconds { get; set; }
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ public sealed class ResourceNodeRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required Vector3 Position { get; init; }
|
||||
public required Vector3 Position { get; set; }
|
||||
public required string SourceKind { get; init; }
|
||||
public required string ItemId { get; init; }
|
||||
public string? AnchorNodeId { get; set; }
|
||||
public float OrbitRadius { get; init; }
|
||||
public float OrbitPhase { get; init; }
|
||||
public float OrbitInclination { get; init; }
|
||||
public float OreRemaining { get; set; }
|
||||
public float MaxOre { get; init; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
|
||||
@@ -14,17 +14,18 @@ public sealed class StationRuntime
|
||||
public string? AnchorNodeId { get; set; }
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> InstalledModules { get; } = [];
|
||||
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, float> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<int, string> DockingPadAssignments { get; } = new();
|
||||
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
|
||||
public float EnergyStored { get; set; }
|
||||
public float ProcessTimer { get; set; }
|
||||
public float Population { get; set; }
|
||||
public float PopulationCapacity { get; set; }
|
||||
public float WorkforceRequired { get; set; }
|
||||
public float WorkforceEffectiveRatio { get; set; } = 0.1f;
|
||||
public float PopulationGrowthProgress { get; set; }
|
||||
public float ShipProductionProgressSeconds { get; set; }
|
||||
public HashSet<string> DockedShipIds { get; } = [];
|
||||
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
|
||||
6
apps/backend/Simulation/OrbitalSimulationOptions.cs
Normal file
6
apps/backend/Simulation/OrbitalSimulationOptions.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class OrbitalSimulationOptions
|
||||
{
|
||||
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
|
||||
}
|
||||
@@ -4,13 +4,18 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class ScenarioLoader
|
||||
{
|
||||
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||
private const string SolSystemId = "sol";
|
||||
private const string DevelopmentCompanionSystemId = "helios";
|
||||
|
||||
private static List<SolarSystemDefinition> InjectSpecialSystems(
|
||||
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
||||
bool includeSolSystem)
|
||||
{
|
||||
var systems = authoredSystems
|
||||
.Select(CloneSystemDefinition)
|
||||
.ToList();
|
||||
|
||||
if (systems.All((system) => system.Id != "sol"))
|
||||
if (includeSolSystem && systems.All((system) => system.Id != "sol"))
|
||||
{
|
||||
systems.Add(CreateSolSystem());
|
||||
}
|
||||
@@ -18,13 +23,25 @@ public sealed partial class ScenarioLoader
|
||||
return systems;
|
||||
}
|
||||
|
||||
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||
private static List<SolarSystemDefinition> ExpandSystems(
|
||||
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
||||
int targetSystemCount)
|
||||
{
|
||||
var systems = authoredSystems
|
||||
.Select(CloneSystemDefinition)
|
||||
.ToList();
|
||||
|
||||
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
|
||||
if (targetSystemCount <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (systems.Count > targetSystemCount)
|
||||
{
|
||||
return TrimSystemsToTarget(systems, targetSystemCount);
|
||||
}
|
||||
|
||||
if (systems.Count >= targetSystemCount || authoredSystems.Count == 0)
|
||||
{
|
||||
return systems;
|
||||
}
|
||||
@@ -32,9 +49,11 @@ public sealed partial class ScenarioLoader
|
||||
var existingIds = systems
|
||||
.Select((system) => system.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
|
||||
var generatedPositions = BuildGalaxyPositions(
|
||||
authoredSystems.Select((system) => ToVector(system.Position)).ToList(),
|
||||
targetSystemCount - systems.Count);
|
||||
|
||||
for (var index = systems.Count; index < TargetSystemCount; index += 1)
|
||||
for (var index = systems.Count; index < targetSystemCount; index += 1)
|
||||
{
|
||||
var template = authoredSystems[index % authoredSystems.Count];
|
||||
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
|
||||
@@ -50,6 +69,63 @@ public sealed partial class ScenarioLoader
|
||||
return systems;
|
||||
}
|
||||
|
||||
private static List<SolarSystemDefinition> TrimSystemsToTarget(
|
||||
IReadOnlyList<SolarSystemDefinition> systems,
|
||||
int targetSystemCount)
|
||||
{
|
||||
var selected = new List<SolarSystemDefinition>(targetSystemCount);
|
||||
|
||||
void AddById(string systemId)
|
||||
{
|
||||
var system = systems.FirstOrDefault((candidate) => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
|
||||
if (system is not null && selected.All((candidate) => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
|
||||
{
|
||||
selected.Add(system);
|
||||
}
|
||||
}
|
||||
|
||||
AddById(SolSystemId);
|
||||
AddById(DevelopmentCompanionSystemId);
|
||||
|
||||
foreach (var system in systems)
|
||||
{
|
||||
if (selected.Count >= targetSystemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (selected.Any((candidate) => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.Add(system);
|
||||
}
|
||||
|
||||
if (selected.Count > 0 && selected.Count <= 4)
|
||||
{
|
||||
ApplyCompactGalaxyLayout(selected);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static void ApplyCompactGalaxyLayout(IReadOnlyList<SolarSystemDefinition> systems)
|
||||
{
|
||||
var compactPositions = new[]
|
||||
{
|
||||
new[] { 0f, 0f, 0f },
|
||||
new[] { 2600f, 24f, -420f },
|
||||
new[] { -2400f, -36f, 560f },
|
||||
new[] { 520f, 42f, 2480f },
|
||||
};
|
||||
|
||||
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
|
||||
{
|
||||
systems[index].Position = compactPositions[index];
|
||||
}
|
||||
}
|
||||
|
||||
private static SolarSystemDefinition CreateGeneratedSystem(
|
||||
SolarSystemDefinition template,
|
||||
string label,
|
||||
@@ -66,6 +142,9 @@ public sealed partial class ScenarioLoader
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -118,8 +197,12 @@ public sealed partial class ScenarioLoader
|
||||
ResourceNodes = definition.ResourceNodes
|
||||
.Select((node) => new ResourceNodeDefinition
|
||||
{
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -161,6 +244,9 @@ public sealed partial class ScenarioLoader
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -239,9 +325,8 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
|
||||
{
|
||||
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
|
||||
var nodeCount = 4 + (generatedIndex % 4);
|
||||
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
|
||||
var oreAmount = 1000f;
|
||||
|
||||
for (var index = 0; index < nodeCount; index += 1)
|
||||
{
|
||||
@@ -249,7 +334,9 @@ public sealed partial class ScenarioLoader
|
||||
{
|
||||
SourceKind = "asteroid-belt",
|
||||
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
|
||||
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
|
||||
RadiusOffset = 120f + Jitter(generatedIndex, 200 + index, 36f),
|
||||
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
|
||||
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
|
||||
OreAmount = oreAmount,
|
||||
ItemId = "ore",
|
||||
ShardCount = 6 + (index % 4),
|
||||
@@ -269,15 +356,27 @@ public sealed partial class ScenarioLoader
|
||||
yield break;
|
||||
}
|
||||
|
||||
var gasAnchorIndex = 0;
|
||||
for (var index = 0; index < planets.Count; index += 1)
|
||||
{
|
||||
if (ReferenceEquals(planets[index], gasAnchor))
|
||||
{
|
||||
gasAnchorIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var nodeCount = 2 + (generatedIndex % 3);
|
||||
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
|
||||
var gasAmount = 1000f;
|
||||
for (var index = 0; index < nodeCount; index += 1)
|
||||
{
|
||||
yield return new ResourceNodeDefinition
|
||||
{
|
||||
SourceKind = "gas-cloud",
|
||||
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
|
||||
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f),
|
||||
RadiusOffset = 170f + Jitter(generatedIndex, 260 + index, 44f),
|
||||
InclinationDegrees = Jitter(generatedIndex, 320 + index, 10f),
|
||||
AnchorPlanetIndex = gasAnchorIndex,
|
||||
OreAmount = gasAmount,
|
||||
ItemId = "gas",
|
||||
ShardCount = 10 + index,
|
||||
@@ -285,19 +384,29 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
}
|
||||
|
||||
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
|
||||
private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)
|
||||
{
|
||||
var gap = planets
|
||||
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
|
||||
.OrderByDescending((entry) => entry.Gap)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (gap.Gap > 1f)
|
||||
if (planets.Count == 0)
|
||||
{
|
||||
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 420f + ((generatedIndex % 5) * 60f);
|
||||
var gasGiantIndex = -1;
|
||||
for (var index = 0; index < planets.Count; index += 1)
|
||||
{
|
||||
if (planets[index].PlanetType is "gas-giant" or "ice-giant")
|
||||
{
|
||||
gasGiantIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (gasGiantIndex > 0)
|
||||
{
|
||||
return gasGiantIndex - 1;
|
||||
}
|
||||
|
||||
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
|
||||
}
|
||||
|
||||
private static List<PlanetDefinition> BuildGeneratedPlanets(
|
||||
@@ -424,6 +533,15 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
private static SolarSystemDefinition CreateSolSystem()
|
||||
{
|
||||
var mercuryOrbitAu = 0.3871f;
|
||||
var venusOrbitAu = 0.7233f;
|
||||
var earthOrbitAu = 1.000f;
|
||||
var marsOrbitAu = 1.5237f;
|
||||
var jupiterOrbitAu = 5.203f;
|
||||
var saturnOrbitAu = 9.582f;
|
||||
var uranusOrbitAu = 19.201f;
|
||||
var neptuneOrbitAu = 30.047f;
|
||||
|
||||
return new SolarSystemDefinition
|
||||
{
|
||||
Id = "sol",
|
||||
@@ -438,30 +556,30 @@ public sealed partial class ScenarioLoader
|
||||
AsteroidField = new AsteroidFieldDefinition
|
||||
{
|
||||
DecorationCount = 240,
|
||||
RadiusOffset = 780f,
|
||||
RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f),
|
||||
RadiusVariance = 180f,
|
||||
HeightVariance = 22f,
|
||||
},
|
||||
ResourceNodes =
|
||||
[
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, ItemId = "gas", ShardCount = 10 },
|
||||
],
|
||||
Planets =
|
||||
[
|
||||
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
|
||||
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
|
||||
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
|
||||
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
|
||||
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
|
||||
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
|
||||
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
|
||||
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
|
||||
CreateSolPlanet("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
|
||||
CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
|
||||
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
|
||||
CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
|
||||
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
|
||||
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, saturnOrbitAu, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
|
||||
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
|
||||
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, neptuneOrbitAu, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -471,8 +589,7 @@ public sealed partial class ScenarioLoader
|
||||
string planetType,
|
||||
string shape,
|
||||
int moonCount,
|
||||
float orbitRadius,
|
||||
float orbitSpeed,
|
||||
float orbitRadiusAu,
|
||||
float orbitEccentricity,
|
||||
float orbitInclination,
|
||||
float ascendingNode,
|
||||
@@ -488,8 +605,8 @@ public sealed partial class ScenarioLoader
|
||||
PlanetType = planetType,
|
||||
Shape = shape,
|
||||
MoonCount = moonCount,
|
||||
OrbitRadius = orbitRadius,
|
||||
OrbitSpeed = orbitSpeed,
|
||||
OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu),
|
||||
OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
|
||||
OrbitEccentricity = orbitEccentricity,
|
||||
OrbitInclination = orbitInclination,
|
||||
OrbitLongitudeOfAscendingNode = ascendingNode,
|
||||
@@ -506,4 +623,13 @@ public sealed partial class ScenarioLoader
|
||||
HasRing = hasRing,
|
||||
};
|
||||
}
|
||||
|
||||
private static float ScaleSolOrbitRadiusFromAu(float orbitRadiusAu) =>
|
||||
MathF.Round(500f * MathF.Pow(orbitRadiusAu, 0.70f));
|
||||
|
||||
private static float ComputeSolOrbitSpeed(float orbitRadiusAu)
|
||||
{
|
||||
const float earthAngularSpeed = 0.11f;
|
||||
return earthAngularSpeed / MathF.Sqrt(orbitRadiusAu * orbitRadiusAu * orbitRadiusAu);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ public sealed partial class ScenarioLoader
|
||||
factionIds.Add(DefaultFactionId);
|
||||
}
|
||||
|
||||
return factionIds.Select(CreateFaction).ToList();
|
||||
factionIds.Add(UnclaimedFactionId);
|
||||
|
||||
return factionIds
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(CreateFaction)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static FactionRuntime CreateFaction(string factionId)
|
||||
@@ -35,6 +40,13 @@ public sealed partial class ScenarioLoader
|
||||
Color = "#7ed4ff",
|
||||
Credits = MinimumFactionCredits,
|
||||
},
|
||||
UnclaimedFactionId => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
Label = "Unclaimed",
|
||||
Color = "#7f8794",
|
||||
Credits = 0f,
|
||||
},
|
||||
_ => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
@@ -89,30 +101,32 @@ public sealed partial class ScenarioLoader
|
||||
IReadOnlyCollection<NodeRuntime> nodes,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
var claims = new List<ClaimRuntime>(stations.Count);
|
||||
foreach (var station in stations)
|
||||
var stationsByAnchorNodeId = stations
|
||||
.Where((station) => station.AnchorNodeId is not null)
|
||||
.ToDictionary((station) => station.AnchorNodeId!, StringComparer.Ordinal);
|
||||
var claims = new List<ClaimRuntime>();
|
||||
foreach (var node in nodes.Where((candidate) => candidate.Kind == SpatialNodeKind.LagrangePoint))
|
||||
{
|
||||
if (station.AnchorNodeId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
|
||||
if (anchorNode is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station)
|
||||
? station.FactionId
|
||||
: UnclaimedFactionId;
|
||||
var activatesAtUtc = owningFactionId == UnclaimedFactionId
|
||||
? nowUtc
|
||||
: nowUtc.AddSeconds(8);
|
||||
var state = owningFactionId == UnclaimedFactionId
|
||||
? ClaimStateKinds.Active
|
||||
: ClaimStateKinds.Activating;
|
||||
|
||||
claims.Add(new ClaimRuntime
|
||||
{
|
||||
Id = $"claim-{station.Id}",
|
||||
FactionId = station.FactionId,
|
||||
SystemId = station.SystemId,
|
||||
NodeId = anchorNode.Id,
|
||||
BubbleId = anchorNode.BubbleId,
|
||||
Id = $"claim-{node.Id}",
|
||||
FactionId = owningFactionId,
|
||||
SystemId = node.SystemId,
|
||||
NodeId = node.Id,
|
||||
BubbleId = node.BubbleId,
|
||||
PlacedAtUtc = nowUtc,
|
||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||
State = ClaimStateKinds.Activating,
|
||||
ActivatesAtUtc = activatesAtUtc,
|
||||
State = state,
|
||||
Health = 100f,
|
||||
});
|
||||
}
|
||||
@@ -138,8 +152,13 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
|
||||
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
|
||||
var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}");
|
||||
if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
if (anchorNode is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var claim = claims.FirstOrDefault((candidate) => candidate.NodeId == anchorNode.Id);
|
||||
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -192,9 +211,20 @@ public sealed partial class ScenarioLoader
|
||||
StationRuntime station,
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
|
||||
{
|
||||
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
||||
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("solar-array", 2),
|
||||
("dock-bay-small", 2),
|
||||
})
|
||||
{
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& moduleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
@@ -220,6 +250,11 @@ public sealed partial class ScenarioLoader
|
||||
var policies = new List<PolicySetRuntime>(factions.Count);
|
||||
foreach (var faction in factions)
|
||||
{
|
||||
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var policyId = $"policy-{faction.Id}";
|
||||
faction.DefaultPolicySetId = policyId;
|
||||
policies.Add(new PolicySetRuntime
|
||||
@@ -244,6 +279,11 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
foreach (var faction in factions)
|
||||
{
|
||||
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var commander = new CommanderRuntime
|
||||
{
|
||||
Id = $"commander-faction-{faction.Id}",
|
||||
@@ -251,9 +291,16 @@ public sealed partial class ScenarioLoader
|
||||
FactionId = faction.Id,
|
||||
ControlledEntityId = faction.Id,
|
||||
PolicySetId = faction.DefaultPolicySetId,
|
||||
Doctrine = "strategic-default",
|
||||
Doctrine = "strategic-expansionist",
|
||||
};
|
||||
|
||||
commander.Goals.Add("control-all-systems");
|
||||
commander.Goals.Add("control-five-systems-fast");
|
||||
commander.Goals.Add("expand-industrial-base");
|
||||
commander.Goals.Add("grow-war-fleet");
|
||||
commander.Goals.Add("deter-pirate-harassment");
|
||||
commander.Goals.Add("contest-rival-expansion");
|
||||
|
||||
commanders.Add(commander);
|
||||
factionCommanders[faction.Id] = commander;
|
||||
faction.CommanderIds.Add(commander.Id);
|
||||
@@ -334,7 +381,7 @@ public sealed partial class ScenarioLoader
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null)
|
||||
if (string.Equals(definition.Role, "construction", StringComparison.Ordinal) && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
@@ -345,24 +392,23 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
|
||||
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
|
||||
{
|
||||
return CreateResourceHarvestBehavior("auto-harvest-gas", scenario.MiningDefaults.NodeSystemId, refinery.Id);
|
||||
}
|
||||
|
||||
if (string.Equals(definition.Role, "transport", StringComparison.Ordinal) && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "auto-harvest-gas",
|
||||
Kind = "auto-supply-energy",
|
||||
StationId = refinery.Id,
|
||||
Phase = "travel-to-node",
|
||||
Phase = "travel-to-source",
|
||||
};
|
||||
}
|
||||
|
||||
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "auto-mine",
|
||||
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
|
||||
StationId = refinery.Id,
|
||||
Phase = "travel-to-node",
|
||||
};
|
||||
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
|
||||
}
|
||||
|
||||
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
|
||||
@@ -381,6 +427,14 @@ public sealed partial class ScenarioLoader
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
|
||||
{
|
||||
Kind = kind,
|
||||
AreaSystemId = areaSystemId,
|
||||
StationId = stationId,
|
||||
Phase = "travel-to-node",
|
||||
};
|
||||
|
||||
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
|
||||
{
|
||||
Kind = behavior.Kind,
|
||||
@@ -402,7 +456,7 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
|
||||
{
|
||||
Kind = task.Kind,
|
||||
Kind = task.Kind.ToContractValue(),
|
||||
Status = task.Status,
|
||||
TargetEntityId = task.TargetEntityId,
|
||||
TargetNodeId = targetNodeId ?? task.TargetNodeId,
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader
|
||||
parentNodeId: starNode.Id);
|
||||
|
||||
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
|
||||
{
|
||||
var lagrangeNode = AddSpatialNode(
|
||||
nodes,
|
||||
@@ -111,22 +111,41 @@ public sealed partial class ScenarioLoader
|
||||
return node;
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
||||
Vector3 planetPosition,
|
||||
float orbitRadius,
|
||||
float planetSize,
|
||||
int planetIndex)
|
||||
{
|
||||
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
||||
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
||||
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
|
||||
var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
|
||||
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, -orbitRadius));
|
||||
yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
|
||||
{
|
||||
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
|
||||
var sizeScale = (planetSize * 1.9f) + 10f;
|
||||
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
|
||||
}
|
||||
|
||||
private static StationPlacement ResolveStationPlacement(
|
||||
@@ -172,6 +191,39 @@ public sealed partial class ScenarioLoader
|
||||
_ => "L1",
|
||||
};
|
||||
|
||||
private static NodeRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
|
||||
{
|
||||
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.Nodes.FirstOrDefault((node) => node.Id == moonNodeId);
|
||||
}
|
||||
|
||||
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
|
||||
return graph.Nodes.FirstOrDefault((node) => node.Id == planetNodeId);
|
||||
}
|
||||
|
||||
private static Vector3 ComputeResourceNodePosition(NodeRuntime? anchorNode, ResourceNodeDefinition definition, float yPlane)
|
||||
{
|
||||
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.18f, 28f);
|
||||
var offset = new Vector3(
|
||||
MathF.Cos(definition.Angle) * definition.RadiusOffset,
|
||||
verticalOffset,
|
||||
MathF.Sin(definition.Angle) * definition.RadiusOffset);
|
||||
|
||||
if (anchorNode is null)
|
||||
{
|
||||
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
|
||||
}
|
||||
|
||||
return Add(anchorNode.Position, offset);
|
||||
}
|
||||
|
||||
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
|
||||
{
|
||||
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
public sealed partial class ScenarioLoader
|
||||
{
|
||||
private const string DefaultFactionId = "sol-dominion";
|
||||
private const int TargetSystemCount = 160;
|
||||
private const string UnclaimedFactionId = "unclaimed";
|
||||
private const int WorldSeed = 1;
|
||||
private const float MinimumFactionCredits = 0f;
|
||||
private const float MinimumRefineryOre = 0f;
|
||||
@@ -76,20 +76,27 @@ public sealed partial class ScenarioLoader
|
||||
];
|
||||
|
||||
private readonly string _dataRoot;
|
||||
private readonly WorldGenerationOptions _worldGeneration;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public ScenarioLoader(string contentRootPath)
|
||||
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
|
||||
{
|
||||
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
|
||||
_worldGeneration = worldGeneration ?? new WorldGenerationOptions();
|
||||
}
|
||||
|
||||
public SimulationWorld Load()
|
||||
{
|
||||
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json")));
|
||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
|
||||
var systems = ExpandSystems(
|
||||
InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem),
|
||||
_worldGeneration.TargetSystemCount);
|
||||
var scenario = NormalizeScenarioToAvailableSystems(
|
||||
Read<ScenarioDefinition>("scenario.json"),
|
||||
systems.Select((system) => system.Id).ToList());
|
||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
||||
var items = Read<List<ItemDefinition>>("items.json");
|
||||
@@ -127,18 +134,21 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
foreach (var system in systemRuntimes)
|
||||
{
|
||||
var systemGraph = systemGraphs[system.Definition.Id];
|
||||
foreach (var node in system.Definition.ResourceNodes)
|
||||
{
|
||||
var anchorNode = ResolveResourceNodeAnchor(systemGraph, node);
|
||||
var resourceNode = new ResourceNodeRuntime
|
||||
{
|
||||
Id = $"node-{++nodeIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Position = new Vector3(
|
||||
MathF.Cos(node.Angle) * node.RadiusOffset,
|
||||
balance.YPlane,
|
||||
MathF.Sin(node.Angle) * node.RadiusOffset),
|
||||
Position = ComputeResourceNodePosition(anchorNode, node, balance.YPlane),
|
||||
SourceKind = node.SourceKind,
|
||||
ItemId = node.ItemId,
|
||||
AnchorNodeId = anchorNode?.Id,
|
||||
OrbitRadius = node.RadiusOffset,
|
||||
OrbitPhase = node.Angle,
|
||||
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
|
||||
OreRemaining = node.OreAmount,
|
||||
MaxOre = node.OreAmount,
|
||||
};
|
||||
@@ -152,6 +162,7 @@ public sealed partial class ScenarioLoader
|
||||
Kind = SpatialNodeKind.ResourceSite,
|
||||
Position = resourceNode.Position,
|
||||
BubbleId = bubbleId,
|
||||
ParentNodeId = anchorNode?.Id,
|
||||
});
|
||||
localBubbles.Add(new LocalBubbleRuntime
|
||||
{
|
||||
@@ -230,10 +241,15 @@ public sealed partial class ScenarioLoader
|
||||
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
||||
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
|
||||
|
||||
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
|
||||
(route) => route.SystemId,
|
||||
(route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
var patrolRoutes = 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);
|
||||
|
||||
var shipsRuntime = new List<ShipRuntime>();
|
||||
var shipIdCounter = 0;
|
||||
@@ -258,7 +274,7 @@ public sealed partial class ScenarioLoader
|
||||
TargetPosition = position,
|
||||
SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes),
|
||||
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
|
||||
@@ -306,6 +322,7 @@ public sealed partial class ScenarioLoader
|
||||
ItemDefinitions = itemDefinitions,
|
||||
ModuleRecipes = moduleRecipeDefinitions,
|
||||
Recipes = recipeDefinitions,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -318,6 +335,60 @@ public sealed partial class ScenarioLoader
|
||||
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
||||
}
|
||||
|
||||
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyList<string> availableSystemIds)
|
||||
{
|
||||
if (availableSystemIds.Count == 0)
|
||||
{
|
||||
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
|
||||
{
|
||||
InitialStations = scenario.InitialStations
|
||||
.Select((station) => new InitialStationDefinition
|
||||
{
|
||||
ConstructibleId = station.ConstructibleId,
|
||||
SystemId = ResolveSystemId(station.SystemId),
|
||||
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,
|
||||
})
|
||||
.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 static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||
|
||||
private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
|
||||
|
||||
@@ -7,18 +7,19 @@ public sealed partial class SimulationEngine
|
||||
var task = ship.ControllerTask;
|
||||
return task.Kind switch
|
||||
{
|
||||
"idle" => UpdateIdle(ship, world, deltaSeconds),
|
||||
"travel" => UpdateTravel(ship, world, deltaSeconds),
|
||||
"extract" => UpdateExtract(ship, world, deltaSeconds),
|
||||
"dock" => UpdateDock(ship, world, deltaSeconds),
|
||||
"unload" => UpdateUnload(ship, world, deltaSeconds),
|
||||
"refuel" => UpdateRefuel(ship, world, deltaSeconds),
|
||||
"deliver-construction" => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
||||
"build-construction-site" => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
||||
"load-workers" => UpdateLoadWorkers(ship, world, deltaSeconds),
|
||||
"unload-workers" => UpdateUnloadWorkers(ship, world, deltaSeconds),
|
||||
"construct-module" => UpdateConstructModule(ship, world, deltaSeconds),
|
||||
"undock" => UpdateUndock(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Refuel => UpdateRefuel(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.LoadWorkers => UpdateLoadWorkers(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.UnloadWorkers => UpdateUnloadWorkers(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
|
||||
_ => UpdateIdle(ship, world, deltaSeconds),
|
||||
};
|
||||
}
|
||||
@@ -26,7 +27,7 @@ public sealed partial class SimulationEngine
|
||||
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -36,7 +37,7 @@ public sealed partial class SimulationEngine
|
||||
var task = ship.ControllerTask;
|
||||
if (task.TargetPosition is null || task.TargetSystemId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -47,7 +48,9 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (ship.SystemId != task.TargetSystemId)
|
||||
{
|
||||
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode);
|
||||
var destinationEntryNode = ResolveSystemEntryNode(world, task.TargetSystemId);
|
||||
var destinationEntryPosition = destinationEntryNode?.Position ?? Vector3.Zero;
|
||||
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryNode);
|
||||
}
|
||||
|
||||
var currentNode = ResolveCurrentNode(world, ship);
|
||||
@@ -95,6 +98,11 @@ public sealed partial class SimulationEngine
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static NodeRuntime? ResolveSystemEntryNode(SimulationWorld world, string systemId) =>
|
||||
world.SpatialNodes.FirstOrDefault(candidate =>
|
||||
candidate.SystemId == systemId &&
|
||||
candidate.Kind == SpatialNodeKind.Star);
|
||||
|
||||
private string UpdateLocalTravel(
|
||||
ShipRuntime ship,
|
||||
SimulationWorld world,
|
||||
@@ -119,19 +127,19 @@ public sealed partial class SimulationEngine
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
||||
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
||||
ship.State = "arriving";
|
||||
ship.State = ShipState.Arriving;
|
||||
return "arrived";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "local-flight";
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
@@ -158,32 +166,32 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.DestinationNodeId = targetNode.Id;
|
||||
|
||||
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
if (ship.State != "warping")
|
||||
if (ship.State != ShipState.Warping)
|
||||
{
|
||||
if (ship.State != "spooling-warp")
|
||||
if (ship.State != ShipState.SpoolingWarp)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "spooling-warp";
|
||||
ship.State = ShipState.SpoolingWarp;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "warping";
|
||||
ship.State = ShipState.Warping;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -220,32 +228,32 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.CurrentBubbleId = null;
|
||||
ship.SpatialState.DestinationNodeId = destinationNodeId;
|
||||
|
||||
if (ship.State != "ftl")
|
||||
if (ship.State != ShipState.Ftl)
|
||||
{
|
||||
if (ship.State != "spooling-ftl")
|
||||
if (ship.State != ShipState.SpoolingFtl)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "spooling-ftl";
|
||||
ship.State = ShipState.SpoolingFtl;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "ftl";
|
||||
ship.State = ShipState.Ftl;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -254,7 +262,7 @@ public sealed partial class SimulationEngine
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
|
||||
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
|
||||
return ship.Position.DistanceTo(targetPosition) <= 24f
|
||||
? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode)
|
||||
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode)
|
||||
: "none";
|
||||
}
|
||||
|
||||
@@ -270,7 +278,23 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
||||
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
||||
ship.SpatialState.DestinationNodeId = targetNode?.Id;
|
||||
ship.State = "arriving";
|
||||
ship.State = ShipState.Arriving;
|
||||
return "arrived";
|
||||
}
|
||||
|
||||
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.Position = targetPosition;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
|
||||
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
||||
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
||||
ship.SpatialState.DestinationNodeId = targetNode?.Id;
|
||||
ship.State = ShipState.Arriving;
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,22 +55,57 @@ public sealed partial class SimulationEngine
|
||||
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
|
||||
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
|
||||
{
|
||||
var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f;
|
||||
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180f, 0.45f));
|
||||
}
|
||||
|
||||
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
|
||||
{
|
||||
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
|
||||
var orbit = new Vector3(
|
||||
MathF.Cos(angle) * node.OrbitRadius,
|
||||
0f,
|
||||
MathF.Sin(angle) * node.OrbitRadius);
|
||||
return RotateAroundX(orbit, node.OrbitInclination);
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
||||
Vector3 planetPosition,
|
||||
float orbitRadius,
|
||||
float planetSize,
|
||||
int planetIndex)
|
||||
{
|
||||
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
||||
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
||||
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
|
||||
var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
|
||||
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, -orbitRadius));
|
||||
yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
|
||||
{
|
||||
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
|
||||
var sizeScale = (planetSize * 1.9f) + 10f;
|
||||
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
|
||||
}
|
||||
|
||||
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
|
||||
@@ -125,9 +160,9 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc)
|
||||
private void UpdateOrbitalState(SimulationWorld world)
|
||||
{
|
||||
var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f);
|
||||
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
|
||||
var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
|
||||
foreach (var system in world.Systems)
|
||||
@@ -150,7 +185,7 @@ public sealed partial class SimulationEngine
|
||||
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
|
||||
planetNode.Position = planetPosition;
|
||||
|
||||
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
|
||||
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
|
||||
{
|
||||
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
|
||||
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))
|
||||
@@ -186,6 +221,20 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in world.Nodes)
|
||||
{
|
||||
if (node.AnchorNodeId is null || !spatialNodesById.TryGetValue(node.AnchorNodeId, out var anchorNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
node.Position = Add(anchorNode.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
|
||||
if (spatialNodesById.TryGetValue(node.Id, out var resourceNode))
|
||||
{
|
||||
resourceNode.Position = node.Position;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
|
||||
{
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private const float StationEnergyCellToEnergyRatio = 1f;
|
||||
|
||||
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
|
||||
modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
||||
|
||||
@@ -19,7 +21,7 @@ public sealed partial class SimulationEngine
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
var previousEnergy = station.EnergyStored;
|
||||
GenerateStationEnergy(station, deltaSeconds);
|
||||
GenerateStationEnergy(station, world, deltaSeconds);
|
||||
|
||||
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f)
|
||||
{
|
||||
@@ -39,7 +41,7 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
|
||||
private static void GenerateStationEnergy(StationRuntime station, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var powerCores = CountModules(station.InstalledModules, "power-core");
|
||||
var tanks = CountModules(station.InstalledModules, "liquid-tank");
|
||||
@@ -53,6 +55,32 @@ public sealed partial class SimulationEngine
|
||||
var energyCapacity = powerCores * StationEnergyPerPowerCore;
|
||||
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
|
||||
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
if (desiredEnergy <= 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
||||
station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
|
||||
return;
|
||||
}
|
||||
|
||||
var solarGenerated = MathF.Min(desiredEnergy, GetStationSolarGeneration(station, world) * deltaSeconds);
|
||||
if (solarGenerated > 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + solarGenerated);
|
||||
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
}
|
||||
|
||||
if (desiredEnergy > 0.01f && fuelStored <= 0.01f)
|
||||
{
|
||||
var energyCells = GetInventoryAmount(station.Inventory, "energy-cell");
|
||||
if (energyCells > 0.01f)
|
||||
{
|
||||
var consumedCells = MathF.Min(energyCells, desiredEnergy / StationEnergyCellToEnergyRatio);
|
||||
RemoveInventory(station.Inventory, "energy-cell", consumedCells);
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + (consumedCells * StationEnergyCellToEnergyRatio));
|
||||
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
}
|
||||
}
|
||||
|
||||
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
||||
@@ -69,6 +97,37 @@ public sealed partial class SimulationEngine
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
|
||||
}
|
||||
|
||||
private static float GetStationFuelCapacity(StationRuntime station) =>
|
||||
CountModules(station.InstalledModules, "liquid-tank") * StationFuelPerTank;
|
||||
|
||||
private static float GetStationEnergyCapacity(StationRuntime station) =>
|
||||
CountModules(station.InstalledModules, "power-core") * StationEnergyPerPowerCore;
|
||||
|
||||
private static float GetStationSolarGeneration(StationRuntime station, SimulationWorld world) =>
|
||||
world.Balance.Energy.StationSolarCharge * (1f + CountModules(station.InstalledModules, "solar-array"));
|
||||
|
||||
private static float GetStationStorageCapacity(StationRuntime station, string storageClass)
|
||||
{
|
||||
var baseCapacity = station.Definition.Storage.TryGetValue(storageClass, out var capacity)
|
||||
? capacity
|
||||
: 0f;
|
||||
|
||||
var extraBulkBays = Math.Max(0, CountModules(station.InstalledModules, "bulk-bay") - CountModules(station.Definition.Modules, "bulk-bay"));
|
||||
var extraLiquidTanks = Math.Max(0, CountModules(station.InstalledModules, "liquid-tank") - CountModules(station.Definition.Modules, "liquid-tank"));
|
||||
var extraGasTanks = Math.Max(0, CountModules(station.InstalledModules, "gas-tank") - CountModules(station.Definition.Modules, "gas-tank"));
|
||||
var extraContainerBays = Math.Max(0, CountModules(station.InstalledModules, "container-bay") - CountModules(station.Definition.Modules, "container-bay"));
|
||||
var moduleBonus = storageClass switch
|
||||
{
|
||||
"bulk-solid" => extraBulkBays * 1000f,
|
||||
"bulk-liquid" => extraLiquidTanks * 500f,
|
||||
"bulk-gas" => extraGasTanks * 500f,
|
||||
"container" => extraContainerBays * 800f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return baseCapacity + moduleBonus;
|
||||
}
|
||||
|
||||
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
|
||||
@@ -166,11 +225,265 @@ public sealed partial class SimulationEngine
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||
string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal);
|
||||
|
||||
private static float GetShipFuelCapacity(ShipRuntime ship) =>
|
||||
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
|
||||
|
||||
internal static bool NeedsRefuel(ShipRuntime ship) =>
|
||||
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f);
|
||||
private static float GetShipAvailableEnergyBudget(ShipRuntime ship) =>
|
||||
ship.EnergyStored + (GetInventoryAmount(ship.Inventory, "fuel") * ShipFuelToEnergyRatio);
|
||||
|
||||
private static float GetShipFuelReserve(ShipRuntime ship, float plannedFuel)
|
||||
{
|
||||
var capacity = GetShipFuelCapacity(ship);
|
||||
var reserveRatio = ship.Definition.CargoItemId == "gas" ? 0.4f : 0.3f;
|
||||
var reserve = MathF.Max(16f, MathF.Max(capacity * 0.18f, plannedFuel * reserveRatio));
|
||||
return MathF.Min(capacity, reserve);
|
||||
}
|
||||
|
||||
private static float EstimateFuelForEnergyDemand(ShipRuntime ship, float energyDemand) =>
|
||||
MathF.Max(0f, energyDemand - ship.EnergyStored) / ShipFuelToEnergyRatio;
|
||||
|
||||
private static float EstimateTimedEnergyUse(SimulationWorld world, float durationSeconds, float drainPerSecond) =>
|
||||
MathF.Max(0f, durationSeconds) * drainPerSecond;
|
||||
|
||||
private static float EstimateTravelEnergy(
|
||||
ShipRuntime ship,
|
||||
SimulationWorld world,
|
||||
Vector3 fromPosition,
|
||||
string fromSystemId,
|
||||
Vector3 toPosition,
|
||||
string toSystemId)
|
||||
{
|
||||
if (!string.Equals(fromSystemId, toSystemId, StringComparison.Ordinal))
|
||||
{
|
||||
var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId);
|
||||
var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition;
|
||||
var ftlDistance = fromPosition.DistanceTo(destinationEntryPosition);
|
||||
var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain)
|
||||
+ EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain)
|
||||
+ EstimateInSystemTravelEnergy(ship, world, destinationEntryPosition, toPosition);
|
||||
}
|
||||
|
||||
return EstimateInSystemTravelEnergy(ship, world, fromPosition, toPosition);
|
||||
}
|
||||
|
||||
private static float EstimateInSystemTravelEnergy(ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, Vector3 toPosition)
|
||||
{
|
||||
var distance = fromPosition.DistanceTo(toPosition);
|
||||
if (distance <= world.Balance.ArrivalThreshold)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (distance <= 120f)
|
||||
{
|
||||
var localDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain);
|
||||
}
|
||||
|
||||
var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
var warpDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain)
|
||||
+ EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain);
|
||||
}
|
||||
|
||||
private static float EstimateDockingEnergy(SimulationWorld world) =>
|
||||
EstimateTimedEnergyUse(world, world.Balance.DockingDuration, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, 6f, world.Balance.Energy.IdleDrain);
|
||||
|
||||
private static float EstimateUndockingEnergy(SimulationWorld world) =>
|
||||
EstimateTimedEnergyUse(world, world.Balance.UndockingDuration, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, 4f, world.Balance.Energy.IdleDrain);
|
||||
|
||||
private static float EstimateExtractionEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var remainingCargo = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
|
||||
if (remainingCargo <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var cycles = MathF.Ceiling(remainingCargo / MathF.Max(world.Balance.MiningRate, 0.01f));
|
||||
return EstimateTimedEnergyUse(world, cycles * world.Balance.MiningCycleSeconds, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, cycles * 1.5f, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
private static float EstimateConstructionEnergy(ShipRuntime ship, SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var holdPosition = GetConstructionHoldPosition(station, ship.Id);
|
||||
var travelEnergy = EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, holdPosition, station.SystemId);
|
||||
var site = GetConstructionSiteForStation(world, station.Id);
|
||||
if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
|
||||
{
|
||||
if (world.ModuleRecipes.TryGetValue(site.BlueprintId ?? string.Empty, out var siteRecipe))
|
||||
{
|
||||
return travelEnergy + EstimateTimedEnergyUse(world, siteRecipe.Duration, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
return travelEnergy;
|
||||
}
|
||||
|
||||
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
|
||||
if (moduleId is not null
|
||||
&& world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
|
||||
&& CanStartModuleConstruction(station, recipe))
|
||||
{
|
||||
return travelEnergy + EstimateTimedEnergyUse(world, recipe.Duration, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
return travelEnergy;
|
||||
}
|
||||
|
||||
private static float EstimateResourceHarvestEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var requiredModule = cargoItemId == "gas" ? "gas-extractor" : "mining-turret";
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var refinery = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
var node = behavior.NodeId is null
|
||||
? world.Nodes
|
||||
.Where(candidate =>
|
||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
||||
candidate.ItemId == cargoItemId &&
|
||||
candidate.OreRemaining > 0.01f)
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
|
||||
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
var energy = 0f;
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (ship.DockedStationId == refinery.Id)
|
||||
{
|
||||
currentPosition = GetUndockTargetPosition(refinery, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
|
||||
currentSystemId = refinery.SystemId;
|
||||
energy += EstimateUndockingEnergy(world);
|
||||
}
|
||||
|
||||
if (cargoAmount > 0.01f)
|
||||
{
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId);
|
||||
return energy + EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
var holdPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, holdPosition, node.SystemId);
|
||||
energy += EstimateExtractionEnergy(ship, world);
|
||||
energy += EstimateTravelEnergy(ship, world, holdPosition, node.SystemId, refinery.Position, refinery.SystemId);
|
||||
energy += EstimateDockingEnergy(world);
|
||||
return energy;
|
||||
}
|
||||
|
||||
private static float EstimateResourceReturnEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var refinery = SelectBestBuyStation(world, ship, cargoItemId, ship.DefaultBehavior.StationId);
|
||||
if (refinery is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
return EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId)
|
||||
+ EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
private static float EstimateTransportEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
|
||||
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (source is null && destination is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (dockedStation is not null)
|
||||
{
|
||||
currentPosition = GetUndockTargetPosition(dockedStation, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
|
||||
currentSystemId = dockedStation.SystemId;
|
||||
}
|
||||
}
|
||||
|
||||
var targetStation = cargoAmount > 0.01f ? destination : source;
|
||||
if (targetStation is null)
|
||||
{
|
||||
return ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
|
||||
}
|
||||
|
||||
var energy = ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, targetStation.Position, targetStation.SystemId);
|
||||
return energy + EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
private static float EstimateShipMissionEnergyDemand(ShipRuntime ship, SimulationWorld world) =>
|
||||
ship.DefaultBehavior.Kind switch
|
||||
{
|
||||
"auto-mine" or "auto-harvest-gas" => EstimateResourceHarvestEnergy(ship, world),
|
||||
"auto-supply-energy" => EstimateTransportEnergy(ship, world),
|
||||
"construct-station" when ship.DefaultBehavior.StationId is not null
|
||||
=> world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) is { } station
|
||||
? EstimateConstructionEnergy(ship, world, station)
|
||||
: 0f,
|
||||
_ when ship.ControllerTask.TargetPosition is { } targetPosition && ship.ControllerTask.TargetSystemId is { } targetSystemId
|
||||
=> EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, targetPosition, targetSystemId),
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
private static float GetShipRefuelTarget(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var capacity = GetShipFuelCapacity(ship);
|
||||
var missionFuel = EstimateFuelForEnergyDemand(ship, EstimateShipMissionEnergyDemand(ship, world));
|
||||
var reserveFuel = GetShipFuelReserve(ship, missionFuel);
|
||||
return MathF.Min(capacity, missionFuel + reserveFuel);
|
||||
}
|
||||
|
||||
internal static bool NeedsRefuel(ShipRuntime ship, SimulationWorld world) =>
|
||||
GetInventoryAmount(ship.Inventory, "fuel") + 0.01f < GetShipRefuelTarget(ship, world);
|
||||
|
||||
internal static bool NeedsEmergencyReturn(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
if (ship.DefaultBehavior.Kind is not "auto-mine" and not "auto-harvest-gas")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var returnEnergy = EstimateResourceReturnEnergy(ship, world);
|
||||
var reserveFuel = GetShipFuelReserve(ship, EstimateFuelForEnergyDemand(ship, returnEnergy));
|
||||
var requiredBudget = returnEnergy + (reserveFuel * ShipFuelToEnergyRatio);
|
||||
return GetShipAvailableEnergyBudget(ship) + 0.01f < requiredBudget;
|
||||
}
|
||||
|
||||
private static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
@@ -206,7 +519,8 @@ public sealed partial class SimulationEngine
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
|
||||
var capacity = GetStationStorageCapacity(station, storageClass);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
@@ -232,6 +546,17 @@ public sealed partial class SimulationEngine
|
||||
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
|
||||
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
|
||||
|
||||
private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.All(entry => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value);
|
||||
private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
|
||||
{
|
||||
if (site.StationId is not null
|
||||
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
||||
{
|
||||
return GetInventoryAmount(station.Inventory, itemId);
|
||||
}
|
||||
|
||||
return GetInventoryAmount(site.DeliveredItems, itemId);
|
||||
}
|
||||
|
||||
private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed partial class SimulationEngine
|
||||
world.Seed,
|
||||
sequence,
|
||||
world.TickIntervalMs,
|
||||
world.OrbitalTimeSeconds,
|
||||
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
|
||||
world.GeneratedAtUtc,
|
||||
world.Systems.Select(system => new SystemSnapshot(
|
||||
system.Definition.Id,
|
||||
@@ -59,11 +61,12 @@ public sealed partial class SimulationEngine
|
||||
node.Id,
|
||||
node.SystemId,
|
||||
node.LocalPosition,
|
||||
node.AnchorNodeId,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
node.ItemId)).ToList(),
|
||||
world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot(
|
||||
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
|
||||
station.Id,
|
||||
station.Label,
|
||||
station.Category,
|
||||
@@ -74,8 +77,13 @@ public sealed partial class SimulationEngine
|
||||
station.AnchorNodeId,
|
||||
station.Color,
|
||||
station.DockedShips,
|
||||
station.DockedShipIds,
|
||||
station.DockingPads,
|
||||
station.FuelStored,
|
||||
station.FuelCapacity,
|
||||
station.EnergyStored,
|
||||
station.EnergyCapacity,
|
||||
station.CurrentProcesses,
|
||||
station.Inventory,
|
||||
station.FactionId,
|
||||
station.CommanderId,
|
||||
@@ -135,7 +143,7 @@ public sealed partial class SimulationEngine
|
||||
policy.DockingAccessPolicy,
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy)).ToList(),
|
||||
world.Ships.Select(ToShipDelta).Select(ship => new ShipSnapshot(
|
||||
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
||||
ship.Id,
|
||||
ship.Label,
|
||||
ship.Role,
|
||||
@@ -154,12 +162,14 @@ public sealed partial class SimulationEngine
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.CargoCapacity,
|
||||
ship.CargoItemId,
|
||||
ship.WorkerPopulation,
|
||||
ship.EnergyStored,
|
||||
ship.Inventory,
|
||||
ship.FactionId,
|
||||
ship.Health,
|
||||
ship.History,
|
||||
ship.CurrentAction,
|
||||
ship.SpatialState)).ToList(),
|
||||
world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot(
|
||||
faction.Id,
|
||||
@@ -193,7 +203,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
station.LastDeltaSignature = BuildStationSignature(station);
|
||||
station.LastDeltaSignature = BuildStationSignature(world, station);
|
||||
}
|
||||
|
||||
foreach (var claim in world.Claims)
|
||||
@@ -218,7 +228,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
foreach (var ship in world.Ships)
|
||||
{
|
||||
ship.LastDeltaSignature = BuildShipSignature(ship);
|
||||
ship.LastDeltaSignature = BuildShipSignature(world, ship);
|
||||
}
|
||||
|
||||
foreach (var faction in world.Factions)
|
||||
@@ -286,14 +296,14 @@ public sealed partial class SimulationEngine
|
||||
var deltas = new List<StationDelta>();
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
var signature = BuildStationSignature(station);
|
||||
var signature = BuildStationSignature(world, station);
|
||||
if (signature == station.LastDeltaSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
station.LastDeltaSignature = signature;
|
||||
deltas.Add(ToStationDelta(station));
|
||||
deltas.Add(ToStationDelta(world, station));
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -371,19 +381,19 @@ public sealed partial class SimulationEngine
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
|
||||
private IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
|
||||
{
|
||||
var deltas = new List<ShipDelta>();
|
||||
foreach (var ship in world.Ships)
|
||||
{
|
||||
var signature = BuildShipSignature(ship);
|
||||
var signature = BuildShipSignature(world, ship);
|
||||
if (signature == ship.LastDeltaSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ship.LastDeltaSignature = signature;
|
||||
deltas.Add(ToShipDelta(ship));
|
||||
deltas.Add(ToShipDelta(world, ship));
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -407,7 +417,8 @@ public sealed partial class SimulationEngine
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}";
|
||||
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
|
||||
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.AnchorNodeId}|{node.OreRemaining:0.###}";
|
||||
|
||||
private static string BuildSpatialNodeSignature(NodeRuntime node) =>
|
||||
$"{node.SystemId}|{node.Kind.ToContractValue()}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}";
|
||||
@@ -415,8 +426,34 @@ public sealed partial class SimulationEngine
|
||||
private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) =>
|
||||
$"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal))}";
|
||||
|
||||
private static string BuildStationSignature(StationRuntime station) =>
|
||||
$"{station.SystemId}|{station.NodeId}|{station.BubbleId}|{station.AnchorNodeId}|{station.CommanderId}|{station.PolicySetId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{station.Population:0.###}|{station.PopulationCapacity:0.###}|{station.WorkforceRequired:0.###}|{station.WorkforceEffectiveRatio:0.###}|{string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal))}|{string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}";
|
||||
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var processes = ToStationActionProgressSnapshots(world, station);
|
||||
return string.Join("|",
|
||||
station.SystemId,
|
||||
station.NodeId ?? "none",
|
||||
station.BubbleId ?? "none",
|
||||
station.AnchorNodeId ?? "none",
|
||||
station.CommanderId ?? "none",
|
||||
station.PolicySetId ?? "none",
|
||||
BuildInventorySignature(station.Inventory),
|
||||
GetInventoryAmount(station.Inventory, "fuel").ToString("0.###"),
|
||||
GetStationFuelCapacity(station).ToString("0.###"),
|
||||
station.EnergyStored.ToString("0.###"),
|
||||
GetStationEnergyCapacity(station).ToString("0.###"),
|
||||
string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")),
|
||||
string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)),
|
||||
station.DockingPadAssignments.Count.ToString(),
|
||||
station.Population.ToString("0.###"),
|
||||
station.PopulationCapacity.ToString("0.###"),
|
||||
station.WorkforceRequired.ToString("0.###"),
|
||||
station.WorkforceEffectiveRatio.ToString("0.###"),
|
||||
string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)),
|
||||
string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)),
|
||||
station.ActiveConstruction?.ModuleId ?? "none",
|
||||
station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0",
|
||||
string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}")));
|
||||
}
|
||||
|
||||
private static string BuildClaimSignature(ClaimRuntime claim) =>
|
||||
$"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
|
||||
@@ -430,7 +467,7 @@ public sealed partial class SimulationEngine
|
||||
private static string BuildPolicySignature(PolicySetRuntime policy) =>
|
||||
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
|
||||
|
||||
private static string BuildShipSignature(ShipRuntime ship) =>
|
||||
private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) =>
|
||||
string.Join("|",
|
||||
ship.SystemId,
|
||||
ship.Position.X.ToString("0.###"),
|
||||
@@ -442,10 +479,10 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition.X.ToString("0.###"),
|
||||
ship.TargetPosition.Y.ToString("0.###"),
|
||||
ship.TargetPosition.Z.ToString("0.###"),
|
||||
ship.State,
|
||||
ship.State.ToContractValue(),
|
||||
ship.Order?.Kind ?? "none",
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.ControllerTask.Kind,
|
||||
ship.ControllerTask.Kind.ToContractValue(),
|
||||
ship.SpatialState.CurrentNodeId ?? "none",
|
||||
ship.SpatialState.CurrentBubbleId ?? "none",
|
||||
ship.DockedStationId ?? "none",
|
||||
@@ -463,8 +500,14 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
||||
GetShipCargoAmount(ship).ToString("0.###"),
|
||||
GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"),
|
||||
ship.TrackedActionKey ?? "none",
|
||||
ship.TrackedActionTotal.ToString("0.###"),
|
||||
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
|
||||
? GetRemainingConstructionDelivery(world, site).ToString("0.###")
|
||||
: "0",
|
||||
ship.EnergyStored.ToString("0.###"),
|
||||
ship.Health.ToString("0.###"));
|
||||
ship.Health.ToString("0.###"),
|
||||
ship.ActionTimer.ToString("0.###"));
|
||||
|
||||
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||
string.Join(",",
|
||||
@@ -480,6 +523,7 @@ public sealed partial class SimulationEngine
|
||||
node.Id,
|
||||
node.SystemId,
|
||||
ToDto(node.Position),
|
||||
node.AnchorNodeId,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
@@ -505,7 +549,7 @@ public sealed partial class SimulationEngine
|
||||
bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
|
||||
|
||||
private static StationDelta ToStationDelta(StationRuntime station) => new(
|
||||
private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new(
|
||||
station.Id,
|
||||
station.Definition.Label,
|
||||
station.Definition.Category,
|
||||
@@ -516,8 +560,13 @@ public sealed partial class SimulationEngine
|
||||
station.AnchorNodeId,
|
||||
station.Definition.Color,
|
||||
station.DockedShipIds.Count,
|
||||
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
GetDockingPadCount(station),
|
||||
GetInventoryAmount(station.Inventory, "fuel"),
|
||||
GetStationFuelCapacity(station),
|
||||
station.EnergyStored,
|
||||
GetStationEnergyCapacity(station),
|
||||
ToStationActionProgressSnapshots(world, station),
|
||||
ToInventoryEntries(station.Inventory),
|
||||
station.FactionId,
|
||||
station.CommanderId,
|
||||
@@ -529,6 +578,23 @@ public sealed partial class SimulationEngine
|
||||
station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(),
|
||||
station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList());
|
||||
|
||||
private static IReadOnlyList<StationActionProgressSnapshot> ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) =>
|
||||
GetStationProductionLanes(station)
|
||||
.Select(laneKey =>
|
||||
{
|
||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||
var timer = GetStationProductionTimer(station, laneKey);
|
||||
return recipe is null || station.EnergyStored <= 0.01f || timer <= 0.01f
|
||||
? null
|
||||
: new StationActionProgressSnapshot(
|
||||
laneKey,
|
||||
recipe.Label,
|
||||
Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f));
|
||||
})
|
||||
.Where(snapshot => snapshot is not null)
|
||||
.Cast<StationActionProgressSnapshot>()
|
||||
.ToList();
|
||||
|
||||
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
|
||||
claim.Id,
|
||||
claim.FactionId,
|
||||
@@ -582,7 +648,7 @@ public sealed partial class SimulationEngine
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy);
|
||||
|
||||
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
|
||||
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new(
|
||||
ship.Id,
|
||||
ship.Definition.Label,
|
||||
ship.Definition.Role,
|
||||
@@ -591,24 +657,75 @@ public sealed partial class SimulationEngine
|
||||
ToDto(ship.Position),
|
||||
ToDto(ship.Velocity),
|
||||
ToDto(ship.TargetPosition),
|
||||
ship.State,
|
||||
ship.State.ToContractValue(),
|
||||
ship.Order?.Kind,
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.ControllerTask.Kind,
|
||||
ship.ControllerTask.Kind.ToContractValue(),
|
||||
ship.SpatialState.CurrentNodeId,
|
||||
ship.SpatialState.CurrentBubbleId,
|
||||
ship.DockedStationId,
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.Definition.CargoCapacity,
|
||||
ship.Definition.CargoItemId,
|
||||
ship.WorkerPopulation,
|
||||
ship.EnergyStored,
|
||||
ToInventoryEntries(ship.Inventory),
|
||||
ship.FactionId,
|
||||
ship.Health,
|
||||
ship.History.ToList(),
|
||||
ToShipActionProgressSnapshot(world, ship),
|
||||
ToShipSpatialStateSnapshot(ship.SpatialState));
|
||||
|
||||
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var progress = ship.State switch
|
||||
{
|
||||
ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)),
|
||||
ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
|
||||
ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)),
|
||||
ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
|
||||
ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)),
|
||||
ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)),
|
||||
ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)),
|
||||
ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)),
|
||||
ShipState.Refueling => CreateShipRemainingActionProgress(
|
||||
"Refuel",
|
||||
ship.TrackedActionTotal,
|
||||
MathF.Max(0f, GetShipRefuelTarget(ship, world) - GetInventoryAmount(ship.Inventory, "fuel"))),
|
||||
ShipState.Loading => CreateShipRemainingActionProgress(
|
||||
"Load workers",
|
||||
ship.TrackedActionTotal,
|
||||
MathF.Max(0f, ship.TrackedActionTotal - ship.WorkerPopulation)),
|
||||
ShipState.Unloading => CreateShipRemainingActionProgress(
|
||||
"Unload workers",
|
||||
ship.TrackedActionTotal,
|
||||
ship.WorkerPopulation),
|
||||
ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null
|
||||
? null
|
||||
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
|
||||
? null
|
||||
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) =>
|
||||
new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f));
|
||||
|
||||
private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount)
|
||||
{
|
||||
if (totalAmount <= 0.01f)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f);
|
||||
return new ShipActionProgressSnapshot(label, progress);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
|
||||
inventory
|
||||
.Where(entry => entry.Value > 0.001f)
|
||||
@@ -647,9 +764,9 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static void EmitShipStateEvents(
|
||||
ShipRuntime ship,
|
||||
string previousState,
|
||||
ShipState previousState,
|
||||
string previousBehavior,
|
||||
string previousTask,
|
||||
ControllerTaskKind previousTask,
|
||||
string controllerEvent,
|
||||
ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
@@ -657,7 +774,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (previousState != ship.State)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (previousBehavior != ship.DefaultBehavior.Kind)
|
||||
@@ -667,7 +784,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (previousTask != ship.ControllerTask.Kind)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (controllerEvent != "none")
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed partial class SimulationEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
var remaining = MathF.Max(0f, required - GetInventoryAmount(site.DeliveredItems, order.ItemId));
|
||||
var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
|
||||
order.RemainingAmount = remaining;
|
||||
order.State = remaining <= 0.01f
|
||||
? MarketOrderStateKinds.Filled
|
||||
@@ -78,11 +78,6 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
|
||||
{
|
||||
if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (station.ActiveConstruction is not null)
|
||||
{
|
||||
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
|
||||
@@ -111,9 +106,35 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
|
||||
{
|
||||
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
||||
var priorities = GetFactionExpansionPressure(world, station.FactionId) > 0f
|
||||
? new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("dock-bay-small", 2),
|
||||
("solar-array", 2),
|
||||
}
|
||||
: new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("solar-array", 2),
|
||||
("dock-bay-small", 2),
|
||||
};
|
||||
|
||||
foreach (var (moduleId, targetCount) in priorities)
|
||||
{
|
||||
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& world.ModuleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
@@ -133,7 +154,10 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
order.State = MarketOrderStateKinds.Cancelled;
|
||||
order.RemainingAmount = 0f;
|
||||
world.MarketOrders.Remove(order);
|
||||
}
|
||||
|
||||
station.MarketOrderIds.Remove(orderId);
|
||||
}
|
||||
|
||||
site.MarketOrderIds.Clear();
|
||||
@@ -214,7 +238,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
var padCount = Math.Max(1, GetDockingPadCount(station));
|
||||
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
|
||||
var radius = station.Definition.Radius + 14f;
|
||||
var radius = station.Definition.Radius + 18f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
@@ -225,7 +249,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
var radius = station.Definition.Radius + 34f;
|
||||
var radius = station.Definition.Radius + 24f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
@@ -259,4 +283,25 @@ public sealed partial class SimulationEngine
|
||||
ship.AssignedDockingPadIndex is int padIndex
|
||||
? GetDockingPadPosition(station, padIndex)
|
||||
: station.Position;
|
||||
|
||||
private static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
var radius = station.Definition.Radius + 78f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
station.Position.Z + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
|
||||
private static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
return new Vector3(
|
||||
nodePosition.X + (MathF.Cos(angle) * radius),
|
||||
nodePosition.Y,
|
||||
nodePosition.Z + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ public sealed partial class SimulationEngine
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
|
||||
{
|
||||
if (ship.TrackedActionKey == actionKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.TrackedActionKey = actionKey;
|
||||
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
|
||||
}
|
||||
|
||||
internal static float GetShipCargoAmount(ShipRuntime ship)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
@@ -26,11 +37,20 @@ public sealed partial class SimulationEngine
|
||||
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
|
||||
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node))
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = ShipState.CargoFull;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "cargo-full";
|
||||
}
|
||||
|
||||
ship.TargetPosition = task.TargetPosition.Value;
|
||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||
if (distance > task.Threshold)
|
||||
@@ -38,42 +58,47 @@ public sealed partial class SimulationEngine
|
||||
ship.ActionTimer = 0f;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "mining-approach";
|
||||
ship.State = ShipState.MiningApproach;
|
||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "mining";
|
||||
ship.State = ShipState.Mining;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
|
||||
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
|
||||
mined = MathF.Min(mined, node.OreRemaining);
|
||||
if (mined <= 0.01f)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
|
||||
}
|
||||
|
||||
if (ship.Definition.CargoItemId is not null)
|
||||
{
|
||||
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
|
||||
}
|
||||
|
||||
node.OreRemaining -= mined;
|
||||
if (node.OreRemaining <= 0f)
|
||||
{
|
||||
node.OreRemaining = node.MaxOre;
|
||||
}
|
||||
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
|
||||
|
||||
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
|
||||
}
|
||||
@@ -84,7 +109,7 @@ public sealed partial class SimulationEngine
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
|
||||
if (station is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -93,7 +118,7 @@ public sealed partial class SimulationEngine
|
||||
if (padIndex is null)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "awaiting-dock";
|
||||
ship.State = ShipState.AwaitingDock;
|
||||
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
|
||||
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
|
||||
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
@@ -113,37 +138,37 @@ public sealed partial class SimulationEngine
|
||||
ship.ActionTimer = 0f;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docking-approach";
|
||||
ship.State = ShipState.DockingApproach;
|
||||
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docking";
|
||||
ship.State = ShipState.Docking;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docked";
|
||||
ship.State = ShipState.Docked;
|
||||
ship.DockedStationId = station.Id;
|
||||
station.DockedShipIds.Add(ship.Id);
|
||||
ship.Position = padPosition;
|
||||
@@ -155,7 +180,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -165,15 +190,14 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -181,7 +205,8 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "transferring";
|
||||
ship.State = ShipState.Transferring;
|
||||
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
|
||||
if (cargoItemId is not null)
|
||||
@@ -201,11 +226,11 @@ public sealed partial class SimulationEngine
|
||||
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
|
||||
}
|
||||
|
||||
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -215,15 +240,14 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -231,8 +255,58 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "refueling";
|
||||
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel"));
|
||||
ship.State = ShipState.Loading;
|
||||
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
|
||||
var moved = cargoItemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, cargoItemId));
|
||||
if (cargoItemId is not null && moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(station.Inventory, cargoItemId, moved);
|
||||
AddInventory(ship.Inventory, cargoItemId, moved);
|
||||
}
|
||||
|
||||
return cargoItemId is null
|
||||
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|
||||
|| GetInventoryAmount(station.Inventory, cargoItemId) <= 0.01f
|
||||
? "loaded"
|
||||
: "none";
|
||||
}
|
||||
|
||||
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = ShipState.Refueling;
|
||||
var refuelTarget = GetShipRefuelTarget(ship, world);
|
||||
BeginTrackedAction(ship, "refueling", MathF.Max(0f, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel")));
|
||||
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel"));
|
||||
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
@@ -240,31 +314,40 @@ public sealed partial class SimulationEngine
|
||||
AddInventory(ship.Inventory, "fuel", moved);
|
||||
}
|
||||
|
||||
return !NeedsRefuel(ship) ? "refueled" : "none";
|
||||
return !NeedsRefuel(ship, world) ? "refueled" : "none";
|
||||
}
|
||||
|
||||
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null || ship.DefaultBehavior.ModuleId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
|
||||
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
|
||||
{
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -272,22 +355,22 @@ public sealed partial class SimulationEngine
|
||||
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "waiting-materials";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
|
||||
{
|
||||
ship.State = "construction-blocked";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.ConstructionBlocked;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "constructing";
|
||||
ship.State = ShipState.Constructing;
|
||||
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
|
||||
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
|
||||
{
|
||||
@@ -301,34 +384,49 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
|
||||
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "delivering-construction";
|
||||
ship.State = ShipState.DeliveringConstruction;
|
||||
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
|
||||
|
||||
if (site.StationId is not null)
|
||||
{
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
@@ -349,49 +447,58 @@ public sealed partial class SimulationEngine
|
||||
RemoveInventory(station.Inventory, required.Key, moved);
|
||||
AddInventory(site.Inventory, required.Key, moved);
|
||||
AddInventory(site.DeliveredItems, required.Key, moved);
|
||||
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
|
||||
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = "waiting-materials";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "constructing";
|
||||
ship.State = ShipState.Constructing;
|
||||
site.AssignedConstructorShipIds.Add(ship.Id);
|
||||
site.Progress += deltaSeconds;
|
||||
if (site.Progress < recipe.Duration)
|
||||
@@ -404,22 +511,38 @@ public sealed partial class SimulationEngine
|
||||
return "site-constructed";
|
||||
}
|
||||
|
||||
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
|
||||
ship.DockedStationId is not null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
|
||||
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
|
||||
: null;
|
||||
|
||||
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
|
||||
ship.DockedStationId is not null
|
||||
? GetShipDockedPosition(ship, station)
|
||||
: GetConstructionHoldPosition(station, ship.Id);
|
||||
|
||||
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
|
||||
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
||||
|
||||
private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
||||
{
|
||||
ship.State = "blocked";
|
||||
ship.State = ShipState.Blocked;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (station is null || station.Population <= 0.01f)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
|
||||
var totalTransfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
|
||||
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
||||
if (transfer <= 0.01f)
|
||||
{
|
||||
@@ -428,7 +551,8 @@ public sealed partial class SimulationEngine
|
||||
|
||||
station.Population = MathF.Max(0f, station.Population - transfer);
|
||||
ship.WorkerPopulation += transfer;
|
||||
ship.State = "loading";
|
||||
ship.State = ShipState.Loading;
|
||||
BeginTrackedAction(ship, "loading", totalTransfer);
|
||||
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
|
||||
}
|
||||
|
||||
@@ -436,18 +560,19 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
||||
{
|
||||
ship.State = "blocked";
|
||||
ship.State = ShipState.Blocked;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (station is null || ship.WorkerPopulation <= 0.01f)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
|
||||
var totalTransfer = transfer;
|
||||
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
||||
if (transfer <= 0.01f)
|
||||
{
|
||||
@@ -456,7 +581,8 @@ public sealed partial class SimulationEngine
|
||||
|
||||
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
|
||||
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
|
||||
ship.State = "unloading";
|
||||
ship.State = ShipState.Unloading;
|
||||
BeginTrackedAction(ship, "unloading", totalTransfer);
|
||||
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
|
||||
}
|
||||
|
||||
@@ -465,7 +591,7 @@ public sealed partial class SimulationEngine
|
||||
var task = ship.ControllerTask;
|
||||
if (ship.DockedStationId is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -477,19 +603,19 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition = undockTarget;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "undocking";
|
||||
ship.State = ShipState.Undocking;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
|
||||
{
|
||||
if (station is not null)
|
||||
@@ -516,4 +642,7 @@ public sealed partial class SimulationEngine
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return "undocked";
|
||||
}
|
||||
|
||||
private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = commander.ActiveTask.Kind,
|
||||
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
|
||||
Status = commander.ActiveTask.Status,
|
||||
CommanderId = commander.Id,
|
||||
TargetEntityId = commander.ActiveTask.TargetEntityId,
|
||||
@@ -81,8 +81,8 @@ public sealed partial class SimulationEngine
|
||||
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
|
||||
}
|
||||
|
||||
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind };
|
||||
commander.ActiveTask.Kind = ship.ControllerTask.Kind;
|
||||
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
|
||||
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
|
||||
commander.ActiveTask.Status = ship.ControllerTask.Status;
|
||||
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
|
||||
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
|
||||
@@ -121,7 +121,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
Status = WorkStatus.Active,
|
||||
CommanderId = commander?.Id,
|
||||
TargetSystemId = ship.Order.DestinationSystemId,
|
||||
@@ -144,10 +144,13 @@ public sealed partial class SimulationEngine
|
||||
behavior.StationId = refinery?.Id;
|
||||
var node = behavior.NodeId is null
|
||||
? world.Nodes
|
||||
.Where(candidate => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId)
|
||||
.Where(candidate =>
|
||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
||||
candidate.ItemId == resourceItemId &&
|
||||
candidate.OreRemaining > 0.01f)
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId);
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
|
||||
|
||||
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
||||
{
|
||||
@@ -157,13 +160,29 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
|
||||
behavior.NodeId ??= node.Id;
|
||||
if (NeedsEmergencyReturn(ship, world) && behavior.Phase is "travel-to-node" or "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|
||||
&& behavior.Phase is "travel-to-node" or "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
|
||||
if (ship.DockedStationId == refinery.Id)
|
||||
{
|
||||
if (GetShipCargoAmount(ship) > 0.01f)
|
||||
{
|
||||
behavior.Phase = "unload";
|
||||
}
|
||||
else if (NeedsRefuel(ship))
|
||||
else if (behavior.Phase == "undock")
|
||||
{
|
||||
// Keep the post-refuel departure decision stable for the current dock cycle.
|
||||
behavior.Phase = "undock";
|
||||
}
|
||||
else if (NeedsRefuel(ship, world))
|
||||
{
|
||||
behavior.Phase = "refuel";
|
||||
}
|
||||
@@ -172,7 +191,7 @@ public sealed partial class SimulationEngine
|
||||
behavior.Phase = "undock";
|
||||
}
|
||||
}
|
||||
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock")
|
||||
else if (NeedsRefuel(ship, world) && behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
@@ -180,19 +199,20 @@ public sealed partial class SimulationEngine
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "extract":
|
||||
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "extract",
|
||||
Kind = ControllerTaskKind.Extract,
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
Threshold = 14f,
|
||||
TargetPosition = extractionPosition,
|
||||
Threshold = 5f,
|
||||
};
|
||||
break;
|
||||
case "travel-to-station":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -202,7 +222,7 @@ public sealed partial class SimulationEngine
|
||||
case "dock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "dock",
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -212,7 +232,7 @@ public sealed partial class SimulationEngine
|
||||
case "unload":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "unload",
|
||||
Kind = ControllerTaskKind.Unload,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -222,7 +242,7 @@ public sealed partial class SimulationEngine
|
||||
case "refuel":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "refuel",
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -232,7 +252,7 @@ public sealed partial class SimulationEngine
|
||||
case "undock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "undock",
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
|
||||
@@ -242,7 +262,7 @@ public sealed partial class SimulationEngine
|
||||
default:
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
@@ -278,6 +298,153 @@ public sealed partial class SimulationEngine
|
||||
return bestOrder.Station ?? preferred;
|
||||
}
|
||||
|
||||
internal static StationRuntime? SelectBestSellStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
|
||||
{
|
||||
var preferred = preferredStationId is null
|
||||
? null
|
||||
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
|
||||
|
||||
var bestOrder = world.MarketOrders
|
||||
.Where(order =>
|
||||
order.Kind == MarketOrderKinds.Sell &&
|
||||
order.ConstructionSiteId is null &&
|
||||
order.State != MarketOrderStateKinds.Cancelled &&
|
||||
order.ItemId == itemId &&
|
||||
order.RemainingAmount > 0.01f)
|
||||
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
|
||||
.Where(entry => entry.Station is not null && GetInventoryAmount(entry.Station!.Inventory, itemId) > 0.01f)
|
||||
.OrderByDescending(entry =>
|
||||
{
|
||||
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
|
||||
return entry.Order.Valuation - distancePenalty;
|
||||
})
|
||||
.FirstOrDefault();
|
||||
|
||||
return bestOrder.Station ?? preferred;
|
||||
}
|
||||
|
||||
internal void PlanEnergySupply(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount > 0.01f)
|
||||
{
|
||||
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (destination is null)
|
||||
{
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.StationId = destination.Id;
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "dock":
|
||||
case "unload":
|
||||
case "refuel":
|
||||
case "undock":
|
||||
ship.ControllerTask = CreateStationSupportTask(world, ship, destination, behavior.Phase);
|
||||
break;
|
||||
default:
|
||||
behavior.Phase = "travel-to-destination";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = destination.Id,
|
||||
TargetSystemId = destination.SystemId,
|
||||
TargetPosition = destination.Position,
|
||||
Threshold = 18f,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (source is null)
|
||||
{
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.StationId = source.Id;
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "dock":
|
||||
case "load":
|
||||
case "refuel":
|
||||
case "undock":
|
||||
ship.ControllerTask = CreateStationSupportTask(world, ship, source, behavior.Phase);
|
||||
break;
|
||||
default:
|
||||
behavior.Phase = "travel-to-source";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = source.Id,
|
||||
TargetSystemId = source.SystemId,
|
||||
TargetPosition = source.Position,
|
||||
Threshold = 18f,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
|
||||
phase switch
|
||||
{
|
||||
"dock" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"load" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Load,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"unload" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Unload,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"refuel" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 12f,
|
||||
},
|
||||
"undock" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
|
||||
Threshold = 8f,
|
||||
},
|
||||
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
|
||||
};
|
||||
|
||||
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
@@ -298,17 +465,36 @@ public sealed partial class SimulationEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DockedStationId == station.Id)
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
if (NeedsRefuel(ship))
|
||||
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (dockedStation is not null)
|
||||
{
|
||||
dockedStation.DockedShipIds.Remove(ship.Id);
|
||||
ReleaseDockingPad(dockedStation, ship.Id);
|
||||
}
|
||||
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.Position = GetConstructionHoldPosition(station, ship.Id);
|
||||
ship.TargetPosition = ship.Position;
|
||||
}
|
||||
|
||||
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
|
||||
var isAtConstructionHold = ship.SystemId == station.SystemId
|
||||
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
|
||||
|
||||
if (isAtConstructionHold)
|
||||
{
|
||||
if (NeedsRefuel(ship, world))
|
||||
{
|
||||
behavior.Phase = "refuel";
|
||||
}
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site))
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
|
||||
{
|
||||
behavior.Phase = "deliver-to-site";
|
||||
}
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site))
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
|
||||
{
|
||||
behavior.Phase = "build-site";
|
||||
}
|
||||
@@ -325,81 +511,71 @@ public sealed partial class SimulationEngine
|
||||
behavior.Phase = "wait-for-materials";
|
||||
}
|
||||
}
|
||||
else if (behavior.Phase is not "travel-to-station" and not "dock")
|
||||
else if (behavior.Phase != "travel-to-station")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "dock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "dock",
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = station.Definition.Radius + 4f,
|
||||
};
|
||||
break;
|
||||
case "refuel":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "refuel",
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "construct-module":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "construct-module",
|
||||
Kind = ControllerTaskKind.ConstructModule,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "deliver-to-site":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "deliver-construction",
|
||||
Kind = ControllerTaskKind.DeliverConstruction,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "build-site":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "build-construction-site",
|
||||
Kind = ControllerTaskKind.BuildConstructionSite,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "wait-for-materials":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 0f,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = station.Definition.Radius + 8f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
behavior.Phase = "travel-to-station";
|
||||
break;
|
||||
@@ -412,7 +588,7 @@ public sealed partial class SimulationEngine
|
||||
if (ship.Order is not null && controllerEvent == "arrived")
|
||||
{
|
||||
ship.Order = null;
|
||||
ship.ControllerTask.Kind = "idle";
|
||||
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
|
||||
if (commander is not null)
|
||||
{
|
||||
commander.ActiveOrder = null;
|
||||
@@ -439,16 +615,20 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrackHistory(ShipRuntime ship)
|
||||
private static void TrackHistory(ShipRuntime ship, string controllerEvent)
|
||||
{
|
||||
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
|
||||
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
|
||||
if (signature == ship.LastSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.LastSignature = signature;
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}");
|
||||
var target = ship.ControllerTask.TargetEntityId
|
||||
?? ship.ControllerTask.TargetSystemId
|
||||
?? "none";
|
||||
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
|
||||
if (ship.History.Count > 18)
|
||||
{
|
||||
ship.History.RemoveAt(0);
|
||||
@@ -458,10 +638,27 @@ public sealed partial class SimulationEngine
|
||||
private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
|
||||
new()
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = threshold,
|
||||
};
|
||||
|
||||
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
|
||||
{
|
||||
"travel" => ControllerTaskKind.Travel,
|
||||
"extract" => ControllerTaskKind.Extract,
|
||||
"dock" => ControllerTaskKind.Dock,
|
||||
"load" => ControllerTaskKind.Load,
|
||||
"unload" => ControllerTaskKind.Unload,
|
||||
"refuel" => ControllerTaskKind.Refuel,
|
||||
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
|
||||
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
|
||||
"load-workers" => ControllerTaskKind.LoadWorkers,
|
||||
"unload-workers" => ControllerTaskKind.UnloadWorkers,
|
||||
"construct-module" => ControllerTaskKind.ConstructModule,
|
||||
"undock" => ControllerTaskKind.Undock,
|
||||
_ => ControllerTaskKind.Idle,
|
||||
};
|
||||
|
||||
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
|
||||
{
|
||||
if (commander is null)
|
||||
@@ -471,7 +668,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
commander.ActiveTask = new CommanderTaskRuntime
|
||||
{
|
||||
Kind = task.Kind,
|
||||
Kind = task.Kind.ToContractValue(),
|
||||
Status = task.Status,
|
||||
TargetEntityId = task.TargetEntityId,
|
||||
TargetNodeId = task.TargetNodeId,
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private const int StrategicControlTargetSystems = 5;
|
||||
|
||||
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
@@ -62,18 +64,27 @@ public sealed partial class SimulationEngine
|
||||
|
||||
var desiredOrders = new List<DesiredMarketOrder>();
|
||||
var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f);
|
||||
var energyCellReserve = HasStationModules(station, "power-core", "liquid-tank") ? MathF.Max(20f, CountModules(station.InstalledModules, "power-core") * 40f) : 0f;
|
||||
var waterReserve = MathF.Max(30f, station.Population * 3f);
|
||||
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
|
||||
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
||||
var gasReserve = CanProcessFuel(station) ? 120f : 0f;
|
||||
var shipPartsReserve = HasStationModules(station, "fabricator-array")
|
||||
&& !HasStationModules(station, "component-factory", "ship-factory")
|
||||
&& FactionNeedsMoreWarships(world, station.FactionId)
|
||||
? 90f
|
||||
: 0f;
|
||||
|
||||
AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f);
|
||||
AddDemandOrder(desiredOrders, station, "energy-cell", energyCellReserve, valuationBase: 1.1f);
|
||||
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
||||
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
||||
AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f);
|
||||
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
|
||||
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
|
||||
|
||||
AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f);
|
||||
AddSupplyOrder(desiredOrders, station, "energy-cell", energyCellReserve * 1.8f, reserveFloor: energyCellReserve, valuationBase: 0.82f);
|
||||
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
|
||||
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
|
||||
AddSupplyOrder(desiredOrders, station, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f);
|
||||
@@ -84,56 +95,160 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var recipe = SelectProductionRecipe(world, station);
|
||||
if (recipe is null || station.EnergyStored <= 0.01f)
|
||||
{
|
||||
station.ProcessTimer = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
station.ProcessTimer = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
station.ProcessTimer += deltaSeconds * station.WorkforceEffectiveRatio;
|
||||
if (station.ProcessTimer < recipe.Duration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
station.ProcessTimer = 0f;
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
||||
}
|
||||
|
||||
var produced = 0f;
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
||||
}
|
||||
|
||||
if (produced <= 0.01f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
||||
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
|
||||
if (faction is not null)
|
||||
foreach (var laneKey in GetStationProductionLanes(station))
|
||||
{
|
||||
faction.GoodsProduced += produced;
|
||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null || station.EnergyStored <= 0.01f)
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = GetStationProductionThroughput(station, recipe);
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds * throughput))
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
var produced = 0f;
|
||||
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
|
||||
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
||||
}
|
||||
|
||||
if (recipe.ShipOutputId is not null)
|
||||
{
|
||||
produced += CompleteShipRecipe(world, station, recipe, events);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (produced <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
||||
if (faction is not null)
|
||||
{
|
||||
faction.GoodsProduced += produced;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) =>
|
||||
private static IEnumerable<string> GetStationProductionLanes(StationRuntime station)
|
||||
{
|
||||
if (CountModules(station.InstalledModules, "refinery-stack") > 0)
|
||||
{
|
||||
yield return "refinery";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "fuel-processor") > 0)
|
||||
{
|
||||
yield return "fuel";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "fabricator-array") > 0)
|
||||
{
|
||||
yield return "fabrication";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "component-factory") > 0)
|
||||
{
|
||||
yield return "components";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "ship-factory") > 0)
|
||||
{
|
||||
yield return "shipyard";
|
||||
}
|
||||
}
|
||||
|
||||
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
|
||||
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
|
||||
|
||||
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
|
||||
world.Recipes.Values
|
||||
.Where(recipe => RecipeAppliesToStation(station, recipe))
|
||||
.OrderByDescending(recipe => recipe.Priority)
|
||||
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal))
|
||||
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
||||
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
|
||||
|
||||
private static string? GetStationProductionLaneKey(RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
|
||||
{
|
||||
return "fuel";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
|
||||
{
|
||||
return "refinery";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
|
||||
{
|
||||
return "fabrication";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return "components";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return "shipyard";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var priority = (float)recipe.Priority;
|
||||
var producesFuel = recipe.Outputs.Any(output => string.Equals(output.ItemId, "fuel", StringComparison.Ordinal));
|
||||
if (producesFuel)
|
||||
{
|
||||
var fuelCapacity = MathF.Max(GetStationFuelCapacity(station), 1f);
|
||||
var fuelRatio = GetInventoryAmount(station.Inventory, "fuel") / fuelCapacity;
|
||||
priority += (1f - Math.Clamp(fuelRatio, 0f, 1f)) * 200f;
|
||||
}
|
||||
|
||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||
var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f;
|
||||
priority += recipe.Id switch
|
||||
{
|
||||
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
|
||||
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
||||
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"hull-fabrication" => 180f * expansionPressure,
|
||||
"equipment-assembly" => 170f * expansionPressure,
|
||||
"gun-assembly" => 160f * expansionPressure,
|
||||
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
|
||||
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"ammo-fabrication" => -80f * expansionPressure,
|
||||
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
|
||||
=> -120f * expansionPressure,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal)
|
||||
@@ -144,6 +259,21 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.ShipOutputId is not null)
|
||||
{
|
||||
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
||||
|| !CanLaunchShipFromStation(station))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(shipDefinition.Role, "military", StringComparison.Ordinal)
|
||||
|| !FactionNeedsMoreWarships(world, station.FactionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
|
||||
{
|
||||
return false;
|
||||
@@ -165,7 +295,8 @@ public sealed partial class SimulationEngine
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity))
|
||||
var capacity = GetStationStorageCapacity(station, itemDefinition.Storage);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -259,5 +390,151 @@ public sealed partial class SimulationEngine
|
||||
private static bool CanProcessFuel(StationRuntime station) =>
|
||||
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
|
||||
|
||||
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var spawnPosition = new Vector3(station.Position.X + station.Definition.Radius + 32f, station.Position.Y, station.Position.Z);
|
||||
var ship = new ShipRuntime
|
||||
{
|
||||
Id = $"ship-{world.Ships.Count + 1}",
|
||||
SystemId = station.SystemId,
|
||||
Definition = definition,
|
||||
FactionId = station.FactionId,
|
||||
Position = spawnPosition,
|
||||
TargetPosition = spawnPosition,
|
||||
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
||||
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
||||
ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold),
|
||||
Health = definition.MaxHealth,
|
||||
};
|
||||
|
||||
ship.Inventory["fuel"] = 120f;
|
||||
world.Ships.Add(ship);
|
||||
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
|
||||
{
|
||||
faction.ShipsBuilt += 1;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Definition.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private static bool CanLaunchShipFromStation(StationRuntime station) =>
|
||||
HasStationModules(station, "power-core", "ship-factory", "container-bay", "dock-bay-small");
|
||||
|
||||
private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId)
|
||||
{
|
||||
var militaryShipCount = world.Ships.Count(ship =>
|
||||
ship.FactionId == factionId
|
||||
&& string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal));
|
||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||
var expansionDeficit = Math.Max(0, targetSystems - controlledSystems);
|
||||
var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3));
|
||||
return militaryShipCount < targetWarships;
|
||||
}
|
||||
|
||||
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
|
||||
{
|
||||
return world.Claims
|
||||
.Where(claim => claim.State != ClaimStateKinds.Destroyed)
|
||||
.Select(claim => claim.SystemId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count(systemId => FactionControlsSystem(world, factionId, systemId));
|
||||
}
|
||||
|
||||
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
|
||||
{
|
||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||
var deficit = Math.Max(0, targetSystems - controlledSystems);
|
||||
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
|
||||
}
|
||||
|
||||
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
||||
{
|
||||
var buildableLocations = world.Claims
|
||||
.Where(claim =>
|
||||
claim.SystemId == systemId &&
|
||||
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active)
|
||||
.ToList();
|
||||
if (buildableLocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId);
|
||||
return ownedLocations > (buildableLocations.Count / 2f);
|
||||
}
|
||||
|
||||
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
|
||||
{
|
||||
CurrentSystemId = station.SystemId,
|
||||
SpaceLayer = SpaceLayerKinds.LocalSpace,
|
||||
CurrentNodeId = station.NodeId,
|
||||
CurrentBubbleId = station.BubbleId,
|
||||
LocalPosition = position,
|
||||
SystemPosition = position,
|
||||
MovementRegime = MovementRegimeKinds.LocalFlight,
|
||||
};
|
||||
|
||||
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
||||
{
|
||||
if (!string.Equals(definition.Role, "military", StringComparison.Ordinal))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
};
|
||||
}
|
||||
|
||||
var patrolRadius = station.Definition.Radius + 90f;
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
PatrolPoints =
|
||||
[
|
||||
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius),
|
||||
new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static float GetStationProductionThroughput(StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "refinery-stack"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "fuel-processor"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "fabricator-array"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "component-factory"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "ship-factory"));
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private readonly OrbitalSimulationOptions _orbitalSimulation;
|
||||
private const float ShipFuelToEnergyRatio = 12f;
|
||||
private const float StationFuelToEnergyRatio = 18f;
|
||||
private const float CapacitorEnergyPerModule = 120f;
|
||||
@@ -16,7 +17,7 @@ public sealed partial class SimulationEngine
|
||||
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
|
||||
private static readonly IReadOnlyList<WorldUpdateStep> _worldUpdatePipeline =
|
||||
[
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateOrbitalState(world, nowUtc)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateStationPower(world, deltaSeconds, events)),
|
||||
@@ -29,10 +30,16 @@ public sealed partial class SimulationEngine
|
||||
new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)),
|
||||
];
|
||||
|
||||
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
|
||||
{
|
||||
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
|
||||
}
|
||||
|
||||
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
|
||||
{
|
||||
var events = new List<SimulationEventRecord>();
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
|
||||
|
||||
foreach (var step in _worldUpdatePipeline)
|
||||
{
|
||||
@@ -54,7 +61,7 @@ public sealed partial class SimulationEngine
|
||||
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
||||
AdvanceControlState(ship, world, controllerEvent);
|
||||
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
|
||||
TrackHistory(ship);
|
||||
TrackHistory(ship, controllerEvent);
|
||||
|
||||
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
|
||||
}
|
||||
@@ -65,6 +72,8 @@ public sealed partial class SimulationEngine
|
||||
return new WorldDelta(
|
||||
sequence,
|
||||
world.TickIntervalMs,
|
||||
world.OrbitalTimeSeconds,
|
||||
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
|
||||
world.GeneratedAtUtc,
|
||||
false,
|
||||
events,
|
||||
|
||||
8
apps/backend/Simulation/WorldGenerationOptions.cs
Normal file
8
apps/backend/Simulation/WorldGenerationOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
public int TargetSystemCount { get; init; } = 160;
|
||||
|
||||
public bool IncludeSolSystem { get; init; } = true;
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class WorldService(IWebHostEnvironment environment)
|
||||
public sealed class WorldService(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<WorldGenerationOptions> worldGenerationOptions,
|
||||
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
|
||||
{
|
||||
private const int DeltaHistoryLimit = 256;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
|
||||
private readonly SimulationEngine _engine = 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 Dictionary<Guid, SubscriptionState> _subscribers = [];
|
||||
private readonly Queue<WorldDelta> _history = [];
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
|
||||
private long _sequence;
|
||||
|
||||
public WorldSnapshot GetSnapshot()
|
||||
@@ -98,6 +103,8 @@ public sealed class WorldService(IWebHostEnvironment environment)
|
||||
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")],
|
||||
|
||||
Reference in New Issue
Block a user