Deepen faction economy and station planning

This commit is contained in:
2026-03-19 23:34:06 -04:00
parent 9a5040cf1f
commit cd1fe776a5
33 changed files with 3170 additions and 175 deletions

View File

@@ -293,6 +293,7 @@ public sealed class InitialStationDefinition
public required string SystemId { get; set; }
public string Label { get; set; } = "Orbital Station";
public string Color { get; set; } = "#8df0d2";
public string Objective { get; set; } = "general";
public List<string> StartingModules { get; set; } = [];
public string? FactionId { get; set; }
public int? PlanetIndex { get; set; }
@@ -307,6 +308,7 @@ public sealed class ShipFormationDefinition
public required float[] Center { get; set; }
public required string SystemId { get; set; }
public string? FactionId { get; set; }
public Dictionary<string, float> StartingInventory { get; set; } = new(StringComparer.Ordinal);
}
public sealed class PatrolRouteDefinition

View File

@@ -12,17 +12,22 @@ internal sealed class CommanderPlanningService
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
[
new ExterminateRivalGoal(),
new EnsureWarIndustryGoal(),
new ExpandTerritoryGoal(),
new EnsureWarFleetGoal(),
new EnsureWaterSecurityGoal(),
new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(),
];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[
new SetAttackObjectiveAction(),
new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(),
new SetTradeObjectiveAction(),
new SetIdleObjectiveAction(),
];
@@ -93,6 +98,19 @@ internal sealed class CommanderPlanningService
var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(engine, world, commander);
}
if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null)
{
if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal)))
{
TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId));
}
else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal)))
{
TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"));
}
}
}
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
@@ -124,9 +142,20 @@ internal sealed class CommanderPlanningService
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
{
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
var economy = FactionEconomyAnalyzer.Build(world, factionId);
var refinedMetals = economy.GetCommodity("refinedmetals");
var hullparts = economy.GetCommodity("hullparts");
var claytronics = economy.GetCommodity("claytronics");
var water = economy.GetCommodity("water");
return new FactionPlanningState
{
EnemyFactionCount = world.Factions.Count(f => f.Id != factionId),
EnemyShipCount = world.Ships.Count(s =>
s.Health > 0f &&
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
EnemyStationCount = world.Stations.Count(s =>
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
MilitaryShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
@@ -142,8 +171,19 @@ internal sealed class CommanderPlanningService
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
OreStockpile = economy.GetCommodity("ore").OnHand,
RefinedMetalsStockpile = refinedMetals.OnHand,
RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds,
HullpartsStockpile = hullparts.OnHand,
HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond,
HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds,
ClaytronicsStockpile = claytronics.OnHand,
ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond,
ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds,
WaterStockpile = water.OnHand,
WaterProductionRate = water.ProjectedProductionRatePerSecond,
WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds,
};
}
@@ -156,13 +196,52 @@ internal sealed class CommanderPlanningService
c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
var enemyTarget = SelectEnemyTarget(world, ship);
var tradeRoute = SelectTradeRoute(world, ship.FactionId);
var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId);
if (commander.ActiveBehavior is not null)
{
commander.ActiveBehavior.AreaSystemId = enemyTarget?.SystemId;
commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId;
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
{
commander.ActiveBehavior.ItemId = tradeRoute?.ItemId;
commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId;
commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null)
{
commander.ActiveBehavior.StationId = expansionProject.SupportStationId;
commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId;
commander.ActiveBehavior.ModuleId = expansionProject.ModuleId;
commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
{
commander.ActiveBehavior.TargetEntityId = null;
commander.ActiveBehavior.ModuleId = null;
commander.ActiveBehavior.AreaSystemId = ship.SystemId;
}
}
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false)
? false
: !world.Stations.Any(station =>
string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)
&& station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
TargetEnemySystemId = enemyTarget?.SystemId,
TargetEnemyEntityId = enemyTarget?.EntityId,
TradeItemId = tradeRoute?.ItemId,
TradeSourceStationId = tradeRoute?.SourceStationId,
TradeDestinationStationId = tradeRoute?.DestinationStationId,
};
}
@@ -170,11 +249,15 @@ internal sealed class CommanderPlanningService
{
var actions = new List<GoapAction<FactionPlanningState>>();
actions.Add(new PlanWarIndustryAction());
actions.Add(new PlanCommoditySupplyAction("water"));
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
actions.Add(new LaunchExterminationCampaignAction());
actions.Add(new ExpandToSystemAction());
return actions;
}
@@ -184,4 +267,137 @@ internal sealed class CommanderPlanningService
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
private static void TryQueueFactionExpansionProject(
SimulationWorld world,
CommanderRuntime commander,
IndustryExpansionProject? project)
{
if (project is null)
{
return;
}
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject(
SimulationWorld world,
FactionPlanningState state,
string factionId)
{
if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals");
}
if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts");
}
if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics");
}
if (!state.HasShipFactory)
{
return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId);
}
return null;
}
private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship)
{
var hostileShip = world.Ships
.Where(candidate =>
candidate.Health > 0f &&
!string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.Select(candidate => (candidate.Id, candidate.SystemId))
.FirstOrDefault();
if (hostileShip != default)
{
return hostileShip;
}
var hostileStation = world.Stations
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.Select(candidate => (candidate.Id, candidate.SystemId))
.FirstOrDefault();
return hostileStation == default ? null : hostileStation;
}
private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId)
{
var stationsById = world.Stations
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
.ToDictionary(station => station.Id, StringComparer.Ordinal);
foreach (var demand in world.MarketOrders
.Where(order =>
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
&& order.Kind == MarketOrderKinds.Buy
&& order.RemainingAmount > 0.01f
&& order.StationId is not null)
.OrderByDescending(order => order.Valuation))
{
if (!stationsById.TryGetValue(demand.StationId!, out var destination))
{
continue;
}
if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId))
{
continue;
}
var source = stationsById.Values
.Where(station =>
station.Id != destination.Id
&& GetInventoryAmount(station.Inventory, demand.ItemId) > 1f)
.OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId))
.FirstOrDefault();
if (source is not null)
{
return (demand.ItemId, source.Id, destination.Id);
}
}
return null;
}
private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var definition))
{
return false;
}
var requiredModule = GetStorageRequirement(definition.CargoKind);
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
{
return false;
}
var capacity = GetStationStorageCapacity(station, definition.CargoKind);
if (capacity <= 0.01f)
{
return false;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal))
.Sum(entry => entry.Value);
return used <= capacity - 1f;
}
}

View File

@@ -12,20 +12,89 @@ public sealed class FactionPlanningState
public int ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; }
public bool HasShipFactory { get; set; }
public int EnemyFactionCount { get; set; }
public int EnemyShipCount { get; set; }
public int EnemyStationCount { get; set; }
public float OreStockpile { get; set; }
public float RefinedMetalsStockpile { get; set; }
public float RefinedMetalsProductionRate { get; set; }
public float RefinedMetalsShortageHorizonSeconds { get; set; }
public float HullpartsStockpile { get; set; }
public float HullpartsProductionRate { get; set; }
public float HullpartsShortageHorizonSeconds { get; set; }
public float ClaytronicsStockpile { get; set; }
public float ClaytronicsProductionRate { get; set; }
public float ClaytronicsShortageHorizonSeconds { get; set; }
public float WaterStockpile { get; set; }
public float WaterProductionRate { get; set; }
public float WaterShortageHorizonSeconds { get; set; }
public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f;
public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f;
public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f;
public bool HasWaterProduction => WaterProductionRate > 0.01f;
public bool HasWarIndustrySupplyChain =>
HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction;
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
internal static int ComputeTargetWarships(FactionPlanningState state)
{
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
return Math.Max(2, (state.ControlledSystemCount * 2) + (expansionDeficit * 3));
return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount));
}
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
public sealed class EnsureWarIndustryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-war-industry";
public override bool IsSatisfied(FactionPlanningState state) =>
state.EnemyFactionCount <= 0 || (state.HasWarIndustrySupplyChain && state.HasShipFactory);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (state.EnemyFactionCount <= 0)
{
return 0f;
}
var missingStages =
(state.HasRefinedMetalsProduction ? 0 : 1) +
(state.HasHullpartsProduction ? 0 : 1) +
(state.HasClaytronicsProduction ? 0 : 1) +
(state.HasShipFactory ? 0 : 1);
return missingStages <= 0 ? 0f : 125f + (missingStages * 18f);
}
}
public sealed class EnsureWaterSecurityGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-water-security";
public override bool IsSatisfied(FactionPlanningState state) =>
state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f)
{
return 0f;
}
if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds))
{
return state.HasWaterProduction ? 0f : 85f;
}
return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f;
}
}
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-war-fleet";
@@ -40,6 +109,24 @@ public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
}
}
public sealed class ExterminateRivalGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "exterminate-rival";
public override bool IsSatisfied(FactionPlanningState state) =>
state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (state.EnemyFactionCount <= 0)
{
return 0f;
}
return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f);
}
}
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "expand-territory";
@@ -100,7 +187,8 @@ public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.HasShipFactory && state.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
@@ -121,13 +209,86 @@ public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
}
}
public sealed class PlanWarIndustryAction : GoapAction<FactionPlanningState>
{
public override string Name => "plan-war-industry";
public override float Cost => 2f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory);
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f);
state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f);
state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f);
state.HasShipFactory = true;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("bootstrap-war-industry");
if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project)
{
return;
}
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
}
public sealed class PlanCommoditySupplyAction : GoapAction<FactionPlanningState>
{
private readonly string commodityId;
public PlanCommoditySupplyAction(string commodityId)
{
this.commodityId = commodityId;
}
public override string Name => $"plan-{commodityId}-supply";
public override float Cost => 2f;
public override bool CheckPreconditions(FactionPlanningState state) =>
commodityId switch
{
"water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f,
_ => false,
};
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
if (string.Equals(commodityId, "water", StringComparison.Ordinal))
{
state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f);
state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f);
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project)
{
return;
}
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
}
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
{
public override string Name => "expand-to-system";
public override float Cost => 3f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2;
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
@@ -140,3 +301,28 @@ public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
commander.ActiveDirectives.Add("expand-territory");
}
}
public sealed class LaunchExterminationCampaignAction : GoapAction<FactionPlanningState>
{
public override string Name => "launch-extermination-campaign";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.EnemyFactionCount > 0
&& state.HasShipFactory
&& state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2);
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.EnemyShipCount = 0;
state.EnemyStationCount = 0;
state.EnemyFactionCount = 0;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("attack-rival");
commander.ActiveDirectives.Add("produce-military-ships");
}
}

View File

@@ -9,7 +9,15 @@ public sealed record FactionGoapStateSnapshot(
int TargetSystemCount,
bool HasShipFactory,
float OreStockpile,
float RefinedMetalsStockpile);
float RefinedMetalsStockpile,
float RefinedMetalsProductionRate,
float HullpartsStockpile,
float HullpartsProductionRate,
float ClaytronicsStockpile,
float ClaytronicsProductionRate,
float WaterStockpile,
float WaterProductionRate,
float WaterShortageHorizonSeconds);
public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority);

View File

@@ -45,6 +45,8 @@ public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }

View File

@@ -4,6 +4,7 @@ global using SpaceGame.Api.Economy.Runtime;
global using SpaceGame.Api.Factions.AI;
global using SpaceGame.Api.Factions.Contracts;
global using SpaceGame.Api.Factions.Runtime;
global using SpaceGame.Api.Industry.Planning;
global using SpaceGame.Api.Shared.AI;
global using SpaceGame.Api.Shared.Contracts;
global using SpaceGame.Api.Shared.Runtime;

View File

@@ -0,0 +1,228 @@
namespace SpaceGame.Api.Industry.Planning;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
internal sealed class FactionEconomySnapshot
{
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
internal FactionCommoditySnapshot GetCommodity(string itemId)
{
if (!commodities.TryGetValue(itemId, out var commodity))
{
commodity = new FactionCommoditySnapshot(itemId);
commodities[itemId] = commodity;
}
return commodity;
}
}
internal sealed class FactionCommoditySnapshot
{
internal FactionCommoditySnapshot(string itemId)
{
ItemId = itemId;
}
internal string ItemId { get; }
internal float OnHand { get; set; }
internal float ReservedForConstruction { get; set; }
internal float BuyBacklog { get; set; }
internal float SellBacklog { get; set; }
internal float Inbound { get; set; }
internal float ProductionRatePerSecond { get; set; }
internal float CommittedProductionRatePerSecond { get; set; }
internal float ConsumptionRatePerSecond { get; set; }
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
internal float ShortageHorizonSeconds
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
}
if (NetRatePerSecond >= -0.01f)
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
}
}
internal float ProjectedShortageHorizonSeconds
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
}
if (ProjectedNetRatePerSecond >= -0.01f)
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
}
}
internal float PressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
internal float ProjectedPressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
}
internal static class FactionEconomyAnalyzer
{
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
{
var snapshot = new FactionEconomySnapshot();
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
{
foreach (var (itemId, amount) in station.Inventory)
{
snapshot.GetCommodity(itemId).OnHand += amount;
}
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
{
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
if (recipe is null)
{
continue;
}
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
if (cyclesPerSecond <= 0.0001f)
{
continue;
}
foreach (var input in recipe.Inputs)
{
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
}
foreach (var output in recipe.Outputs)
{
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
}
}
}
foreach (var order in world.MarketOrders.Where(order =>
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
&& order.State != MarketOrderStateKinds.Cancelled
&& order.RemainingAmount > 0.01f))
{
var commodity = snapshot.GetCommodity(order.ItemId);
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
{
commodity.BuyBacklog += order.RemainingAmount;
}
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
{
commodity.SellBacklog += order.RemainingAmount;
}
}
foreach (var site in world.ConstructionSites.Where(site =>
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
{
ApplyCommittedProduction(world, snapshot, site);
foreach (var required in site.RequiredItems)
{
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
if (remaining > 0.01f)
{
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
}
}
}
return snapshot;
}
private static void ApplyCommittedProduction(
SimulationWorld world,
FactionEconomySnapshot snapshot,
ConstructionSiteRuntime site)
{
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
return;
}
var recipeOutputs = world.Recipes.Values
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
.SelectMany(candidate => candidate.Outputs)
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
if (recipeOutputs.Count == 0)
{
return;
}
var materialFraction = 0f;
var materialTerms = 0;
foreach (var required in site.RequiredItems)
{
materialTerms += 1;
materialFraction += required.Value <= 0.01f
? 1f
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
}
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
var buildFraction = recipe.Duration <= 0.01f
? 0f
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
var readiness = site.State switch
{
ConstructionSiteStateKinds.Active => 0.3f,
ConstructionSiteStateKinds.Planned => 0.15f,
_ => 0f,
};
readiness += materialFraction * 0.45f;
readiness += buildFraction * 0.25f;
if (site.AssignedConstructorShipIds.Count > 0)
{
readiness += 0.1f;
}
readiness = Math.Clamp(readiness, 0f, 1f);
if (readiness <= 0.01f)
{
return;
}
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
foreach (var (productItemId, amount) in recipeOutputs)
{
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
}
}
}

View File

@@ -0,0 +1,492 @@
namespace SpaceGame.Api.Industry.Planning;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
internal static class FactionIndustryPlanner
{
internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId)
{
if (HasActiveExpansionProject(world, factionId))
{
return null;
}
var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId);
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity);
if (moduleId is null)
{
return null;
}
var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity);
if (targetCelestial is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
if (supportStation is null)
{
return null;
}
return new IndustryExpansionProject(
bottleneckCommodity,
moduleId,
targetCelestial.SystemId,
targetCelestial.Id,
supportStation.Id);
}
internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId)
{
if (HasActiveExpansionProject(world, factionId))
{
return null;
}
const string shipyardModuleId = "module_gen_build_l_01";
if (world.Stations.Any(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& station.InstalledModules.Contains(shipyardModuleId, StringComparer.Ordinal)))
{
return null;
}
if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe))
{
return null;
}
var bottleneckCommodity = shipyardRecipe.Inputs
.Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId))
.Distinct(StringComparer.Ordinal)
.Select(itemId => new
{
ItemId = itemId,
HasProducer = FactionHasProducerForCommodity(world, factionId, itemId),
Pressure = GetCommodityPressure(world, factionId, itemId),
Stockpile = GetCommodityStockpile(world, factionId, itemId),
})
.Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f)
.OrderByDescending(entry => !entry.HasProducer ? 1 : 0)
.ThenByDescending(entry => entry.Pressure)
.ThenBy(entry => entry.Stockpile)
.Select(entry => entry.ItemId)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(bottleneckCommodity))
{
return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity);
}
return CreateShipyardFoundationProject(world, factionId);
}
internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId)
{
const string shipyardModuleId = "module_gen_build_l_01";
if (HasActiveExpansionProject(world, factionId))
{
return null;
}
var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId);
if (targetCelestial is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId);
if (supportStation is null)
{
return null;
}
return new IndustryExpansionProject(
"shipyard",
shipyardModuleId,
targetCelestial.SystemId,
targetCelestial.Id,
supportStation.Id);
}
internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId)
{
if (HasActiveExpansionProject(world, factionId))
{
return null;
}
var bootstrapCommodity = SelectBootstrapCommodity(world, factionId);
if (bootstrapCommodity is not null)
{
var bootstrapModuleId = world.ProductionGraph.GetPrimaryProducerModule(bootstrapCommodity);
if (bootstrapModuleId is null)
{
return null;
}
var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity);
if (bootstrapCelestial is null)
{
return null;
}
var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId);
if (bootstrapSupportStation is null)
{
return null;
}
return new IndustryExpansionProject(
bootstrapCommodity,
bootstrapModuleId,
bootstrapCelestial.SystemId,
bootstrapCelestial.Id,
bootstrapSupportStation.Id);
}
var commodityId = SelectCommodityToExpand(world, factionId);
if (commodityId is null)
{
return null;
}
var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId);
if (moduleId is null)
{
return null;
}
var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId);
if (targetCelestial is null)
{
return null;
}
var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId);
if (supportStation is null)
{
return null;
}
return new IndustryExpansionProject(
commodityId,
moduleId,
targetCelestial.SystemId,
targetCelestial.Id,
supportStation.Id);
}
internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId)
{
var site = world.ConstructionSites.FirstOrDefault(candidate =>
string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(candidate.TargetKind, "station-foundation", StringComparison.Ordinal)
&& candidate.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
if (site is null || site.BlueprintId is null)
{
return null;
}
var supportStationId = world.Stations
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
.OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0)
.ThenByDescending(station => station.Inventory.Values.Sum())
.Select(station => station.Id)
.FirstOrDefault();
if (supportStationId is null)
{
return null;
}
return new IndustryExpansionProject(
site.TargetDefinitionId,
site.BlueprintId,
site.SystemId,
site.CelestialId,
supportStationId,
site.Id);
}
internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project)
{
if (project.SiteId is not null)
{
return;
}
var nowUtc = DateTimeOffset.UtcNow;
var claimId = $"claim-{factionId}-{project.CelestialId}";
if (world.Claims.All(candidate => candidate.Id != claimId))
{
world.Claims.Add(new ClaimRuntime
{
Id = claimId,
FactionId = factionId,
SystemId = project.SystemId,
CelestialId = project.CelestialId,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
Health = 100f,
});
}
if (!world.ModuleRecipes.TryGetValue(project.ModuleId, out var recipe))
{
return;
}
var siteId = $"site-{factionId}-{project.CelestialId}";
if (world.ConstructionSites.Any(candidate => candidate.Id == siteId))
{
return;
}
var site = new ConstructionSiteRuntime
{
Id = siteId,
FactionId = factionId,
SystemId = project.SystemId,
CelestialId = project.CelestialId,
TargetKind = "station-foundation",
TargetDefinitionId = project.CommodityId,
BlueprintId = project.ModuleId,
ClaimId = claimId,
StationId = null,
State = ConstructionSiteStateKinds.Planned,
};
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{site.Id}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
world.MarketOrders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = factionId,
StationId = project.SupportStationId,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1.1f,
State = MarketOrderStateKinds.Open,
});
}
if (world.Stations.FirstOrDefault(station => station.Id == project.SupportStationId) is { } supportStation)
{
foreach (var orderId in site.MarketOrderIds)
{
supportStation.MarketOrderIds.Add(orderId);
}
}
world.ConstructionSites.Add(site);
}
private static string? SelectCommodityToExpand(SimulationWorld world, string factionId)
{
var demandByItem = world.MarketOrders
.Where(order =>
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal)
&& order.RemainingAmount > 0.01f)
.GroupBy(order => order.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal);
if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships"))
{
demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f;
demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f;
}
return demandByItem
.Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value))
.Where(entry => entry.ItemId is not null)
.GroupBy(entry => entry.ItemId!, StringComparer.Ordinal)
.Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score)))
.OrderByDescending(entry => entry.Score)
.Select(entry => entry.ItemId)
.FirstOrDefault();
}
private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId)
{
if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals"))
{
return "refinedmetals";
}
return null;
}
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId)
{
var visited = new HashSet<string>(StringComparer.Ordinal);
return ResolveBottleneckCommodity(world, factionId, itemId, visited);
}
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet<string> visited)
{
if (!visited.Add(itemId))
{
return itemId;
}
var producers = world.ProductionGraph.GetProcessesForOutput(itemId);
if (producers.Count == 0)
{
return itemId;
}
var hasFactionProducer = producers
.SelectMany(process => process.RequiredModuleIds)
.Any(moduleId => world.Stations.Any(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)));
if (!hasFactionProducer)
{
return itemId;
}
var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId)
.Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId))
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId)))
.OrderByDescending(entry => entry.Score)
.ThenBy(entry => entry.Stockpile)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(weakestUnproducedInput.ItemId)
&& (weakestUnproducedInput.Score > 0.01f || weakestUnproducedInput.Stockpile < 120f))
{
return ResolveBottleneckCommodity(world, factionId, weakestUnproducedInput.ItemId, visited);
}
var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId)
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId)))
.OrderByDescending(entry => entry.Score)
.FirstOrDefault();
return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f
? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited)
: itemId;
}
internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId)
=> FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f;
internal static IReadOnlyCollection<string> ResolveRootResourceItems(SimulationWorld world, string commodityId)
{
var frontier = new Queue<string>();
var resources = new HashSet<string>(StringComparer.Ordinal);
var visited = new HashSet<string>(StringComparer.Ordinal);
frontier.Enqueue(commodityId);
while (frontier.Count > 0)
{
var current = frontier.Dequeue();
if (!visited.Add(current))
{
continue;
}
var inputs = world.ProductionGraph.GetImmediateInputs(current);
if (inputs.Count == 0)
{
resources.Add(current);
continue;
}
foreach (var input in inputs)
{
frontier.Enqueue(input);
}
}
return resources.Count > 0 ? resources : [commodityId];
}
private static bool HasActiveExpansionProject(SimulationWorld world, string factionId) =>
world.ConstructionSites.Any(site =>
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId)
{
return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore;
}
private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) =>
FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock;
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
{
var resourceItems = ResolveRootResourceItems(world, commodityId);
return world.Celestials
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
.OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems))
.FirstOrDefault();
}
private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId)
{
return world.Celestials
.Where(celestial =>
celestial.Kind == SpatialNodeKind.LagrangePoint
&& celestial.OccupyingStructureId is null
&& world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed))
.OrderByDescending(celestial => world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)))
.ThenByDescending(celestial => world.Stations
.Where(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))
.Sum(station => station.Inventory.Values.Sum()))
.FirstOrDefault();
}
private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection<string> resourceItems)
{
var resourceScore = world.Nodes
.Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal))
.Sum(node => node.OreRemaining);
var factionPresence = world.Stations.Count(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal));
return resourceScore + (factionPresence * 5_000f);
}
private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId)
{
var constructionInputs = world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
? recipe.Inputs.Select(input => input.ItemId).ToList()
: [];
return world.Stations
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
.OrderByDescending(station => station.SystemId == targetSystemId ? 1 : 0)
.ThenByDescending(station => constructionInputs.Sum(inputId => GetInventoryAmount(station.Inventory, inputId)))
.ThenByDescending(station => station.Inventory.Values.Sum())
.FirstOrDefault();
}
}
internal sealed record IndustryExpansionProject(
string CommodityId,
string ModuleId,
string SystemId,
string CelestialId,
string SupportStationId,
string? SiteId = null);

View File

@@ -0,0 +1,53 @@
namespace SpaceGame.Api.Industry.Planning;
public sealed class ProductionGraph
{
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
public string? GetPrimaryProducerModule(string itemId) =>
GetProcessesForOutput(itemId)
.SelectMany(process => process.RequiredModuleIds)
.FirstOrDefault();
public string? GetPrimaryOutputForModule(string moduleId) =>
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
? outputs.FirstOrDefault()
: null;
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
GetProcessesForOutput(itemId)
.SelectMany(process => process.Inputs.Keys)
.Distinct(StringComparer.Ordinal)
.ToList();
}
public sealed class ProductionCommodityNode
{
public required string ItemId { get; init; }
public required string Name { get; init; }
public required string Group { get; init; }
public required string CargoKind { get; init; }
public List<string> ProducerProcessIds { get; } = [];
public List<string> ConsumerProcessIds { get; } = [];
}
public sealed class ProductionProcessNode
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string FacilityCategory { get; init; }
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
public required bool ProducesShip { get; init; }
}

View File

@@ -0,0 +1,105 @@
namespace SpaceGame.Api.Industry.Planning;
internal static class ProductionGraphBuilder
{
internal static ProductionGraph Build(
IReadOnlyCollection<ItemDefinition> items,
IReadOnlyCollection<RecipeDefinition> recipes,
IReadOnlyCollection<ModuleDefinition> modules)
{
var commodities = items.ToDictionary(
item => item.Id,
item => new ProductionCommodityNode
{
ItemId = item.Id,
Name = item.Name,
Group = item.Group,
CargoKind = item.CargoKind,
},
StringComparer.Ordinal);
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var recipe in recipes)
{
var outputs = recipe.Outputs
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
var inputs = recipe.Inputs
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
var process = new ProductionProcessNode
{
Id = recipe.Id,
Label = recipe.Label,
FacilityCategory = recipe.FacilityCategory,
RequiredModuleIds = recipe.RequiredModules.ToList(),
Inputs = inputs,
Outputs = outputs,
ProducesShip = recipe.ShipOutputId is not null,
};
processes[process.Id] = process;
foreach (var output in outputs.Keys)
{
if (!commodities.ContainsKey(output))
{
continue;
}
commodities[output].ProducerProcessIds.Add(process.Id);
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
{
outputProcesses = [];
processesByOutputId[output] = outputProcesses;
}
outputProcesses.Add(process);
}
foreach (var input in inputs.Keys)
{
if (!commodities.ContainsKey(input))
{
continue;
}
commodities[input].ConsumerProcessIds.Add(process.Id);
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
{
inputProcesses = [];
processesByInputId[input] = inputProcesses;
}
inputProcesses.Add(process);
}
}
foreach (var module in modules)
{
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
{
outputs = new HashSet<string>(StringComparer.Ordinal);
outputsByModuleId[module.Id] = outputs;
}
foreach (var product in module.Products)
{
outputs.Add(product);
}
}
return new ProductionGraph
{
Commodities = commodities,
Processes = processes,
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
};
}
}

View File

@@ -48,6 +48,7 @@ public enum ShipState
DeliveringConstruction,
Blocked,
Undocking,
EngagingTarget,
}
public enum ControllerTaskKind
@@ -60,6 +61,7 @@ public enum ControllerTaskKind
Unload,
DeliverConstruction,
BuildConstructionSite,
AttackTarget,
ConstructModule,
Undock,
@@ -210,6 +212,7 @@ public static class SimulationEnumMappings
ShipState.DeliveringConstruction => "delivering-construction",
ShipState.Blocked => "blocked",
ShipState.Undocking => "undocking",
ShipState.EngagingTarget => "engaging-target",
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
};
@@ -223,6 +226,7 @@ public static class SimulationEnumMappings
ControllerTaskKind.Unload => "unload",
ControllerTaskKind.DeliverConstruction => "deliver-construction",
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
ControllerTaskKind.AttackTarget => "attack-target",
ControllerTaskKind.ConstructModule => "construct-module",
ControllerTaskKind.Undock => "undock",

View File

@@ -19,6 +19,8 @@ internal sealed class ShipBehaviorStateMachine
{
idleState,
new PatrolShipBehaviorState(),
new AttackTargetShipBehaviorState(),
new TradeHaulShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};

View File

@@ -93,6 +93,9 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
@@ -126,3 +129,58 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
}
}
}
internal sealed class AttackTargetShipBehaviorState : IShipBehaviorState
{
public string Kind => "attack-target";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanAttackTarget(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
if (controllerEvent is "target-destroyed" or "target-lost")
{
ship.DefaultBehavior.TargetEntityId = null;
}
}
}
internal sealed class TradeHaulShipBehaviorState : IShipBehaviorState
{
public string Kind => "trade-haul";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanTransportHaul(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-source", "arrived"):
ship.DefaultBehavior.Phase = "dock-source";
break;
case ("dock-source", "docked"):
ship.DefaultBehavior.Phase = "load";
break;
case ("load", "loaded"):
ship.DefaultBehavior.Phase = "undock-from-source";
break;
case ("undock-from-source", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-destination";
break;
case ("travel-to-destination", "arrived"):
ship.DefaultBehavior.Phase = "dock-destination";
break;
case ("dock-destination", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "undock-from-destination";
break;
case ("undock-from-destination", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-source";
break;
}
}
}

View File

@@ -9,6 +9,13 @@ public sealed class ShipPlanningState
public bool HasMiningCapability { get; set; }
public bool FactionWantsOre { get; set; }
public bool FactionWantsExpansion { get; set; }
public bool FactionWantsCombat { get; set; }
public bool FactionNeedsShipyard { get; set; }
public string? TargetEnemySystemId { get; set; }
public string? TargetEnemyEntityId { get; set; }
public string? TradeItemId { get; set; }
public string? TradeSourceStationId { get; set; }
public string? TradeDestinationStationId { get; set; }
public string? CurrentObjective { get; set; }
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
@@ -102,13 +109,45 @@ public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
}
}
public sealed class SetAttackObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-attack-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "military", StringComparison.Ordinal)
&& state.FactionWantsCombat
&& state.TargetEnemyEntityId is not null;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "attack-target";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null)
{
return;
}
ship.DefaultBehavior.Kind = "attack-target";
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior?.AreaSystemId ?? ship.DefaultBehavior.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
ship.DefaultBehavior.Phase = null;
}
}
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-construction-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) && state.FactionWantsExpansion;
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal)
&& (state.FactionWantsExpansion || state.FactionNeedsShipyard);
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
@@ -129,6 +168,39 @@ public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningStat
}
}
public sealed class SetTradeObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-trade-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "transport", StringComparison.Ordinal)
&& state.TradeItemId is not null
&& state.TradeSourceStationId is not null
&& state.TradeDestinationStationId is not null;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "trade-haul";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || commander.ActiveBehavior is null)
{
return;
}
ship.DefaultBehavior.Kind = "trade-haul";
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
ship.DefaultBehavior.Phase ??= "travel-to-source";
}
}
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-idle-objective";

View File

@@ -42,6 +42,8 @@ public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }

View File

@@ -18,6 +18,8 @@ internal sealed class ShipControlService
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
@@ -61,6 +63,8 @@ internal sealed class ShipControlService
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId;
commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
@@ -140,24 +144,193 @@ internal sealed class ShipControlService
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var target = ResolveAttackTarget(ship, world);
if (target is null)
{
behavior.Kind = "idle";
behavior.TargetEntityId = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.TargetEntityId = target.EntityId;
behavior.AreaSystemId = target.SystemId;
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.AttackTarget,
TargetEntityId = target.EntityId,
TargetSystemId = target.SystemId,
TargetPosition = target.Position,
Threshold = target.AttackRange,
};
}
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId);
if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var carryingCargo = GetShipCargoAmount(ship) > 0.01f;
if (carryingCargo)
{
if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "unload";
}
else if (ship.DockedStationId is not null)
{
behavior.Phase = "undock-from-source";
}
else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload")
{
behavior.Phase = "travel-to-destination";
}
}
else
{
if (ship.DockedStationId == sourceStation.Id)
{
var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId);
behavior.Phase = available > 0.01f ? "load" : "wait-source";
}
else if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "undock-from-destination";
}
else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load")
{
behavior.Phase = "travel-to-source";
}
}
ship.ControllerTask = behavior.Phase switch
{
"travel-to-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
"travel-to-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
var cargoItemId = ship.Inventory.Keys.FirstOrDefault();
var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId);
if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal))
{
behavior.ItemId = targetResourceItemId;
behavior.NodeId = null;
}
var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId);
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 &&
candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
candidate.ItemId == targetResourceItemId &&
candidate.OreRemaining > 0.01f &&
CanShipMineItem(world, ship, candidate.ItemId))
.OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
.ThenByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
: world.Nodes.FirstOrDefault(candidate =>
candidate.Id == behavior.NodeId &&
string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) &&
candidate.OreRemaining > 0.01f);
if (node is not null)
{
behavior.AreaSystemId = node.SystemId;
}
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
{
behavior.Kind = "idle";
if (refinery is null && GetShipCargoAmount(ship) > 0.01f)
{
ship.Inventory.Clear();
}
behavior.Phase = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
@@ -253,6 +426,55 @@ internal sealed class ShipControlService
}
}
private static string SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string fallbackItemId)
{
var candidateItemId = world.MarketOrders
.Where(order =>
string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal)
&& order.Kind == MarketOrderKinds.Buy
&& order.RemainingAmount > 0.01f)
.SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId)
.Select(itemId => new
{
ItemId = itemId,
Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation),
}))
.Where(entry =>
CanShipMineItem(world, ship, entry.ItemId)
&& world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
.GroupBy(entry => entry.ItemId, StringComparer.Ordinal)
.Select(group => new
{
ItemId = group.Key,
Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f),
})
.OrderByDescending(entry => entry.Score)
.Select(entry => entry.ItemId)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(candidateItemId))
{
return candidateItemId;
}
if (CanShipMineItem(world, ship, fallbackItemId)
&& world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
{
return fallbackItemId;
}
return world.Nodes
.Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId))
.OrderByDescending(node => node.OreRemaining)
.Select(node => node.ItemId)
.FirstOrDefault() ?? fallbackItemId;
}
private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) =>
world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)
&& string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal)
&& HasShipCapabilities(ship.Definition, "mining");
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
@@ -267,7 +489,8 @@ internal sealed class ShipControlService
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)
.Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal))
.Where(entry => CanStationReceiveItem(world, entry.Station!, itemId))
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
@@ -275,7 +498,18 @@ internal sealed class ShipControlService
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null);
}
private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal);
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
@@ -320,7 +554,9 @@ internal sealed class ShipControlService
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId)
? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId)
: station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
@@ -328,6 +564,13 @@ internal sealed class ShipControlService
return;
}
if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId))
{
behavior.TargetEntityId = null;
behavior.ModuleId = null;
site = GetConstructionSiteForStation(world, station.Id);
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
@@ -347,13 +590,17 @@ internal sealed class ShipControlService
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id);
ship.Position = ResolveConstructionHoldPosition(ship, station, site, world);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
var isAtConstructionHold = ship.SystemId == station.SystemId
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
var targetSystemId = site?.SystemId ?? station.SystemId;
var targetCelestialId = site?.CelestialId ?? station.CelestialId;
var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId)
&& string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal);
var isAtConstructionHold = ship.SystemId == targetSystemId
&& (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial);
if (isAtConstructionHold)
{
@@ -390,7 +637,7 @@ internal sealed class ShipControlService
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -400,7 +647,7 @@ internal sealed class ShipControlService
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -410,7 +657,7 @@ internal sealed class ShipControlService
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -419,8 +666,8 @@ internal sealed class ShipControlService
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
@@ -429,8 +676,8 @@ internal sealed class ShipControlService
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
@@ -539,6 +786,7 @@ internal sealed class ShipControlService
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"attack-target" => ControllerTaskKind.AttackTarget,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
@@ -563,4 +811,62 @@ internal sealed class ShipControlService
Threshold = task.Threshold,
};
}
private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (site is null || site.StationId is not null)
{
return GetConstructionHoldPosition(station, ship.Id);
}
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
var anchorPosition = anchor?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world)
{
if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId))
{
var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!);
if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal))
{
return direct;
}
}
var hostileShips = world.Ships
.Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f))
.ToList();
var hostileStations = world.Stations
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f))
.ToList();
var preferredSystemId = ship.DefaultBehavior.AreaSystemId;
return hostileShips
.Concat(hostileStations)
.OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1)
.ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId)
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f);
if (ship is not null)
{
return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f);
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId);
return station is null
? null
: new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f);
}
private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange);
}

View File

@@ -166,10 +166,12 @@ internal sealed partial class ShipTaskExecutionService
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
var transferredAny = false;
foreach (var (itemId, amount) in ship.Inventory.ToList())
{
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
transferredAny |= accepted > 0.01f;
RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
{
@@ -178,6 +180,12 @@ internal sealed partial class ShipTaskExecutionService
}
}
if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining"))
{
ship.Inventory.Clear();
return "unloaded";
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
@@ -239,7 +247,7 @@ internal sealed partial class ShipTaskExecutionService
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
var supportPosition = ResolveShipSupportPosition(ship, station, null, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
@@ -296,7 +304,7 @@ internal sealed partial class ShipTaskExecutionService
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
@@ -313,6 +321,28 @@ internal sealed partial class ShipTaskExecutionService
if (site.StationId is not null)
{
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
@@ -359,7 +389,7 @@ internal sealed partial class ShipTaskExecutionService
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
@@ -386,8 +416,16 @@ internal sealed partial class ShipTaskExecutionService
return "none";
}
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
if (site.StationId is null)
{
CompleteStationFoundation(world, station, site);
}
else
{
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
}
return "site-constructed";
}
@@ -398,10 +436,21 @@ internal sealed partial class ShipTaskExecutionService
? 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 Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (ship.DockedStationId is not null)
{
return GetShipDockedPosition(ship, station);
}
if (site?.StationId is null && site is not null)
{
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
return GetConstructionHoldPosition(station, ship.Id);
}
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
@@ -453,4 +502,88 @@ internal sealed partial class ShipTaskExecutionService
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
{
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
if (anchor is null || site.BlueprintId is null)
{
site.State = ConstructionSiteStateKinds.Destroyed;
return;
}
var station = new StationRuntime
{
Id = $"station-{world.Stations.Count + 1}",
SystemId = site.SystemId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = anchor.Position,
FactionId = site.FactionId,
CelestialId = site.CelestialId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
{
AddStationModule(world, station, moduleId);
}
world.Stations.Add(station);
StationLifecycleService.EnsureStationCommander(world, station);
anchor.OccupyingStructureId = station.Id;
site.StationId = station.Id;
PrepareNextConstructionSiteStep(world, station, site);
}
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
{
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
{
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
var storageModule = GetStorageRequirement(itemDefinition.CargoKind);
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
{
modules.Add(storageModule);
}
else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
}
}
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
{
modules.Add("module_arg_stor_container_m_01");
}
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
{
modules.Add("module_gen_prod_energycells_01");
}
modules.Add(primaryModuleId);
return modules.Distinct(StringComparer.Ordinal).ToList();
}
private static string DetermineFoundationObjective(string commodityId) =>
commodityId switch
{
"energycells" => "power",
"water" => "water",
"refinedmetals" => "refinery",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"shipyard" => "shipyard",
_ => "general",
};
private static string BuildFoundedStationLabel(string commodityId) =>
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
}

View File

@@ -6,6 +6,10 @@ namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
private const float WarpEngageDistanceKilometers = 250_000f;
private const float FrigateDps = 7f;
private const float DestroyerDps = 12f;
private const float CruiserDps = 18f;
private const float CapitalDps = 26f;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
@@ -30,6 +34,7 @@ internal sealed partial class ShipTaskExecutionService
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
@@ -47,6 +52,11 @@ internal sealed partial class ShipTaskExecutionService
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return UpdateTravel(ship, world, deltaSeconds, task);
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ControllerTaskRuntime task)
{
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = ShipState.Idle;
@@ -94,6 +104,66 @@ internal sealed partial class ShipTaskExecutionService
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (string.IsNullOrWhiteSpace(task.TargetEntityId))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f);
var hostileStation = hostileShip is null
? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId)
: null;
if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal))
|| (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal)))
{
return "target-lost";
}
if (hostileShip is null && hostileStation is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "target-lost";
}
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
var attackTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = task.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
Threshold = attackRange,
};
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
{
return UpdateTravel(ship, world, deltaSeconds, attackTask);
}
ship.State = ShipState.EngagingTarget;
ship.TargetPosition = targetPosition;
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
var damage = GetShipDamagePerSecond(ship) * deltaSeconds;
if (hostileShip is not null)
{
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
return hostileShip.Health <= 0f ? "target-destroyed" : "none";
}
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f);
return hostileStation.Health <= 0f ? "target-destroyed" : "none";
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
@@ -309,4 +379,14 @@ internal sealed partial class ShipTaskExecutionService
ship.State = ShipState.Arriving;
return "none";
}
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
ship.Definition.Class switch
{
"frigate" => FrigateDps,
"destroyer" => DestroyerDps,
"cruiser" => CruiserDps,
"capital" => CapitalDps,
_ => 4f,
};
}

View File

@@ -39,8 +39,13 @@ public sealed class SimulationEngine
_commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events);
_stationLifecycle.UpdateStations(world, deltaSeconds, events);
foreach (var ship in world.Ships)
foreach (var ship in world.Ships.ToList())
{
if (ship.Health <= 0f)
{
continue;
}
var previousPosition = ship.Position;
var previousState = ship.State;
var previousBehavior = ship.DefaultBehavior.Kind;
@@ -58,6 +63,7 @@ public sealed class SimulationEngine
}
_orbitalStateUpdater.SyncSpatialState(world);
CleanupDestroyedEntities(world, events);
world.GeneratedAtUtc = nowUtc;
return _projection.BuildDelta(world, sequence, events);
@@ -75,6 +81,60 @@ public sealed class SimulationEngine
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanStationConstruction(ship, world);
internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanAttackTarget(ship, world);
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanTransportHaul(ship, world);
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private static void CleanupDestroyedEntities(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList())
{
world.Ships.Remove(ship);
if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation)
{
dockedStation.DockedShipIds.Remove(ship.Id);
dockedStation.DockingPadAssignments.Remove(ship.AssignedDockingPadIndex ?? -1);
}
if (world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId) is { } faction)
{
faction.ShipsLost += 1;
}
if (ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId) is { } commander)
{
commander.IsAlive = false;
}
events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow));
}
foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList())
{
world.Stations.Remove(station);
if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial)
{
celestial.OccupyingStructureId = null;
}
foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId))
{
claim.Health = 0f;
claim.State = ClaimStateKinds.Destroyed;
}
foreach (var site in world.ConstructionSites.Where(candidate => candidate.StationId == station.Id))
{
site.State = ConstructionSiteStateKinds.Destroyed;
}
events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow));
}
}
}

View File

@@ -101,6 +101,7 @@ internal sealed class SimulationProjectionService
station.Id,
station.Label,
station.Category,
station.Objective,
station.SystemId,
station.LocalPosition,
station.CelestialId,
@@ -544,6 +545,7 @@ internal sealed class SimulationProjectionService
station.Id,
station.Label,
station.Category,
station.Objective,
station.SystemId,
ToDto(station.Position),
station.CelestialId,
@@ -770,8 +772,16 @@ internal sealed class SimulationProjectionService
ps.ControlledSystemCount,
ps.TargetSystemCount,
ps.HasShipFactory,
ps.OreStockpile,
ps.RefinedMetalsStockpile);
NormalizeFiniteFloat(ps.OreStockpile),
NormalizeFiniteFloat(ps.RefinedMetalsStockpile),
NormalizeFiniteFloat(ps.RefinedMetalsProductionRate),
NormalizeFiniteFloat(ps.HullpartsStockpile),
NormalizeFiniteFloat(ps.HullpartsProductionRate),
NormalizeFiniteFloat(ps.ClaytronicsStockpile),
NormalizeFiniteFloat(ps.ClaytronicsProductionRate),
NormalizeFiniteFloat(ps.WaterStockpile),
NormalizeFiniteFloat(ps.WaterProductionRate),
NormalizeFiniteFloat(ps.WaterShortageHorizonSeconds));
}
if (commander?.LastGoalPriorities is { } prios)
@@ -811,4 +821,7 @@ internal sealed class SimulationProjectionService
state.Transit.Progress));
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
private static float NormalizeFiniteFloat(float value) =>
float.IsFinite(value) ? value : -1f;
}

View File

@@ -8,6 +8,7 @@ public sealed record StationSnapshot(
string Id,
string Label,
string Category,
string Objective,
string SystemId,
Vector3Dto LocalPosition,
string? CelestialId,
@@ -32,6 +33,7 @@ public sealed record StationDelta(
string Id,
string Label,
string Category,
string Objective,
string SystemId,
Vector3Dto LocalPosition,
string? CelestialId,

View File

@@ -6,6 +6,7 @@ public sealed class StationRuntime
public required string SystemId { get; init; }
public required string Label { get; set; }
public string Category { get; set; } = "station";
public string Objective { get; set; } = "general";
public string Color { get; set; } = "#8df0d2";
public required Vector3 Position { get; set; }
public float Radius { get; set; } = 24f;
@@ -14,6 +15,8 @@ public sealed class StationRuntime
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public List<StationModuleRuntime> Modules { get; } = [];
public float Health { get; set; } = 600f;
public float MaxHealth { get; set; } = 600f;
public IEnumerable<string> InstalledModules => Modules.Select((module) => module.ModuleId);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);

View File

@@ -105,16 +105,66 @@ internal sealed class InfrastructureSimulationService
internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{
// Expand storage before it becomes a bottleneck
const float StorageExpansionThreshold = 0.85f;
var storageExpansionCandidates = new[]
var economy = FactionEconomyAnalyzer.Build(world, station.FactionId);
return GetModuleExpansionCandidates(world, station, economy)
.Where(candidate => world.ModuleRecipes.ContainsKey(candidate.ModuleId))
.OrderByDescending(candidate => candidate.Score)
.Select(candidate => candidate.ModuleId)
.FirstOrDefault();
}
private static IReadOnlyList<ModuleExpansionCandidate> GetModuleExpansionCandidates(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy)
{
var role = StationSimulationService.DetermineStationRole(station);
var candidates = new Dictionary<string, float>(StringComparer.Ordinal);
var constructionDemandByItem = GetOutstandingConstructionDemand(world, station.FactionId);
var objectiveCommodity = GetObjectiveCommodityId(role);
var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodity);
if (objectiveModuleId is not null && world.ModuleRecipes.TryGetValue(objectiveModuleId, out var objectiveRecipe))
{
AddOrRaiseCandidate(candidates, objectiveModuleId, ScoreObjectiveModule(world, station, economy, constructionDemandByItem, objectiveCommodity, objectiveModuleId));
foreach (var storageModuleId in GetRequiredStorageModules(world, objectiveRecipe))
{
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
{
AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: true));
}
}
if (objectiveCommodity is not null
&& world.ProductionGraph.GetImmediateInputs(objectiveCommodity).Contains("energycells", StringComparer.Ordinal))
{
AddOrRaiseCandidate(candidates, "module_gen_prod_energycells_01", ScoreEnergySupportModule(world, station, economy, constructionDemandByItem));
}
}
AddOrRaiseCandidate(candidates, "module_arg_dock_m_01_lowtech", ScoreDockModule(station));
AddOrRaiseCandidate(candidates, "module_arg_hab_m_01", ScoreHabitationModule(station, world, economy));
foreach (var storageModuleId in GetStoragePressureCandidates(world, station))
{
AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: false));
}
return candidates
.Where(entry => entry.Value > 0.01f)
.Select(entry => new ModuleExpansionCandidate(entry.Key, entry.Value))
.ToList();
}
private static IEnumerable<string> GetStoragePressureCandidates(SimulationWorld world, StationRuntime station)
{
foreach (var (storageClass, moduleId) in new[]
{
("solid", "module_arg_stor_solid_m_01"),
("liquid", "module_arg_stor_liquid_m_01"),
("container", "module_arg_stor_container_m_01"),
};
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
})
{
var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
@@ -123,51 +173,552 @@ internal sealed class InfrastructureSimulationService
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value);
if (used / capacity >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId))
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value);
if (used / capacity >= 0.65f)
{
return moduleId;
yield return moduleId;
}
}
var priorities = StationSimulationService.GetFactionExpansionPressure(world, station.FactionId) > 0f
? new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_arg_dock_m_01_lowtech", 2),
("module_gen_prod_energycells_01", 2),
}
: new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
};
foreach (var (moduleId, targetCount) in priorities)
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static IEnumerable<string> GetRequiredStorageModules(SimulationWorld world, ModuleRecipeDefinition recipe)
{
var itemIds = recipe.Inputs.Select(input => input.ItemId);
foreach (var itemId in itemIds)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
continue;
}
if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
else
{
yield return "module_arg_stor_container_m_01";
}
}
if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition))
{
foreach (var productItemId in moduleDefinition.Products)
{
if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition))
{
continue;
}
if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
else
{
yield return "module_arg_stor_container_m_01";
}
}
}
}
private static string? GetObjectiveCommodityId(string role) =>
role switch
{
"power" => "energycells",
"refinery" => "refinedmetals",
"water" => "water",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
_ => null,
};
private static string? GetObjectiveModuleId(SimulationWorld world, string role, string? objectiveCommodityId) =>
role switch
{
"shipyard" => "module_gen_build_l_01",
_ => objectiveCommodityId is null ? null : world.ProductionGraph.GetPrimaryProducerModule(objectiveCommodityId),
};
private static float ScoreObjectiveModule(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
IReadOnlyDictionary<string, float> constructionDemandByItem,
string? objectiveCommodityId,
string objectiveModuleId)
{
if (string.IsNullOrWhiteSpace(objectiveCommodityId))
{
var hasShipyard = CountModules(station.InstalledModules, objectiveModuleId);
return hasShipyard == 0 ? 240f : 0f;
}
var commodity = economy.GetCommodity(objectiveCommodityId);
var currentCount = CountModules(station.InstalledModules, objectiveModuleId);
var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId);
var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem);
var score = 90f + commodity.ProjectedPressureScore + (marginalOutputRate * 900f) + constructionImpact;
if (currentCount == 0)
{
score += 80f;
}
if (!float.IsPositiveInfinity(commodity.ProjectedShortageHorizonSeconds))
{
score += MathF.Max(0f, 300f - commodity.ProjectedShortageHorizonSeconds) * 0.3f;
}
score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId);
score *= EstimateProducerReadiness(world, station, economy, objectiveModuleId, objectiveCommodityId);
score += EstimateImmediateProducerActivationScore(world, station, economy, objectiveModuleId, objectiveCommodityId);
return score - (currentCount * 35f);
}
private static float ScoreEnergySupportModule(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
IReadOnlyDictionary<string, float> constructionDemandByItem)
{
var energy = economy.GetCommodity("energycells");
var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01");
var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem);
var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01");
var score = 40f + energy.ProjectedPressureScore * 0.5f + constructionImpact + readinessUnlock;
if (currentCount == 0)
{
score += 70f;
}
if (!float.IsPositiveInfinity(energy.ProjectedShortageHorizonSeconds))
{
score += MathF.Max(0f, 240f - energy.ProjectedShortageHorizonSeconds) * 0.2f;
}
return score - (currentCount * 40f);
}
private static float ScoreStorageModule(
SimulationWorld world,
StationRuntime station,
string storageModuleId,
string? objectiveModuleId,
string? objectiveCommodityId,
bool requiredByObjective)
{
var storageClass = storageModuleId switch
{
"module_arg_stor_solid_m_01" => "solid",
"module_arg_stor_liquid_m_01" => "liquid",
_ => "container",
};
var capacity = GetStationStorageCapacity(station, storageClass);
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value);
var utilization = capacity <= 0.01f ? 0f : used / capacity;
var score = requiredByObjective ? 140f : 0f;
score += MathF.Max(0f, utilization - 0.6f) * 240f;
if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId))
{
var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass)
|| CommodityUsesStorageClass(world, objectiveCommodityId, storageClass);
if (objectiveUsesStorage)
{
score += 35f;
score += EstimateSupportUnlockScore(world, station, economy: null, supportModuleId: storageModuleId);
}
}
return score;
}
private static float ScoreDockModule(StationRuntime station)
{
var dockingPads = GetDockingPadCount(station);
var dockedShips = station.DockedShipIds.Count;
if (dockingPads <= 0)
{
return 150f;
}
return dockedShips >= dockingPads ? 80f : dockingPads < 4 ? 25f : 0f;
}
private static float ScoreHabitationModule(StationRuntime station)
{
if (station.WorkforceRequired <= 0.01f)
{
return 0f;
}
return station.WorkforceEffectiveRatio < 0.75f
? 30f
: station.WorkforceEffectiveRatio < 0.95f
? 10f
: 0f;
}
private static float ScoreHabitationModule(StationRuntime station, SimulationWorld world, FactionEconomySnapshot economy)
{
return ScoreHabitationModule(station) + EstimateSupportUnlockScore(world, station, economy, "module_arg_hab_m_01");
}
private static void AddOrRaiseCandidate(IDictionary<string, float> candidates, string moduleId, float score)
{
if (score <= 0.01f)
{
return;
}
if (!candidates.TryGetValue(moduleId, out var existing) || score > existing)
{
candidates[moduleId] = score;
}
}
private static float EstimateMarginalOutputRate(
SimulationWorld world,
StationRuntime station,
string moduleId,
string commodityId)
{
var recipe = world.Recipes.Values
.Where(recipe =>
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
&& StationSimulationService.RecipeAppliesToStation(station, recipe))
.Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
.OrderByDescending(recipe => recipe.Priority)
.FirstOrDefault();
if (recipe is null)
{
return 0f;
}
var amount = recipe.Outputs
.Where(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))
.Sum(output => output.Amount);
return amount * station.WorkforceEffectiveRatio / MathF.Max(recipe.Duration, 0.01f);
}
private static float EstimateObjectiveExpansionFeasibility(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
string moduleId,
string commodityId)
{
var recipes = world.Recipes.Values
.Where(recipe =>
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
&& StationSimulationService.RecipeAppliesToStation(station, recipe)
&& recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
.ToList();
if (recipes.Count == 0)
{
return 1f;
}
var feasibility = 1f;
foreach (var recipe in recipes)
{
foreach (var input in recipe.Inputs)
{
var inputCommodity = economy.GetCommodity(input.ItemId);
if (inputCommodity.AvailableStock <= 0.01f && inputCommodity.ProjectedProductionRatePerSecond <= 0.01f)
{
feasibility *= 0.65f;
continue;
}
if (!float.IsPositiveInfinity(inputCommodity.ProjectedShortageHorizonSeconds)
&& inputCommodity.ProjectedShortageHorizonSeconds < 180f)
{
feasibility *= 0.82f;
}
}
}
return Math.Clamp(feasibility, 0.35f, 1.15f);
}
private static float EstimateProducerReadiness(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
string moduleId,
string commodityId)
{
var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId);
return analysis.Readiness;
}
private static ProducerLaneAnalysis AnalyzeProducerLane(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
string moduleId,
string commodityId)
{
var recipe = world.Recipes.Values
.Where(recipe =>
string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)
&& StationSimulationService.RecipeAppliesToStation(station, recipe)
&& recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)))
.OrderByDescending(recipe => recipe.Priority)
.FirstOrDefault();
if (recipe is null)
{
return new ProducerLaneAnalysis(1f, 1f, false, false, false, false);
}
var workforceFactor = station.WorkforceEffectiveRatio < 0.45f
? 0.75f
: station.WorkforceEffectiveRatio < 0.75f
? 0.88f
: 1f;
var inputFactor = 1f;
var missingLocalInputs = false;
var missingFactionInputs = false;
foreach (var input in recipe.Inputs)
{
var localAmount = GetInventoryAmount(station.Inventory, input.ItemId);
var commodity = economy.GetCommodity(input.ItemId);
if (localAmount + 0.001f >= input.Amount)
{
continue;
}
missingLocalInputs = true;
var shortage = input.Amount - localAmount;
var availableStockRatio = commodity.AvailableStock <= 0.01f ? 0f : MathF.Min(1f, commodity.AvailableStock / MathF.Max(input.Amount, 0.01f));
if (commodity.AvailableStock >= shortage)
{
inputFactor *= 0.95f + (availableStockRatio * 0.05f);
}
else if (commodity.ProjectedProductionRatePerSecond > 0.01f)
{
inputFactor *= 0.82f + (availableStockRatio * 0.08f);
}
else
{
inputFactor *= 0.55f + (availableStockRatio * 0.15f);
missingFactionInputs = true;
}
}
var outputReady = true;
foreach (var output in recipe.Outputs)
{
if (!CanStationAcceptStationOutputSoon(world, station, output.ItemId, output.Amount))
{
outputReady = false;
}
}
var readiness = Math.Clamp(workforceFactor * inputFactor * (outputReady ? 1f : 0.72f), 0.4f, 1.1f);
return new ProducerLaneAnalysis(
readiness,
workforceFactor,
missingLocalInputs,
missingFactionInputs,
!outputReady,
outputReady && inputFactor >= 0.9f);
}
private static float EstimateSupportUnlockScore(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot? economy,
string supportModuleId)
{
var role = StationSimulationService.DetermineStationRole(station);
var objectiveCommodityId = GetObjectiveCommodityId(role);
var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodityId);
if (string.IsNullOrWhiteSpace(objectiveCommodityId) || string.IsNullOrWhiteSpace(objectiveModuleId))
{
return 0f;
}
var analysis = economy is null
? new ProducerLaneAnalysis(0.75f, 1f, false, false, false, false)
: AnalyzeProducerLane(world, station, economy, objectiveModuleId, objectiveCommodityId);
var unlockScore = 0f;
switch (supportModuleId)
{
case "module_arg_hab_m_01" when analysis.WorkforceFactor < 0.9f
&& !analysis.HasMissingFactionInputs
&& !analysis.HasMissingOutputStorage:
unlockScore += (1f - analysis.WorkforceFactor) * 150f;
break;
case "module_gen_prod_energycells_01":
if (ObjectiveNeedsEnergy(world, objectiveCommodityId)
&& analysis.HasMissingLocalInputs
&& (economy?.GetCommodity("energycells").AvailableStock ?? 0f) < 120f)
{
unlockScore += 90f;
}
break;
case "module_arg_stor_container_m_01":
case "module_arg_stor_solid_m_01":
case "module_arg_stor_liquid_m_01":
var storageClass = supportModuleId switch
{
"module_arg_stor_solid_m_01" => "solid",
"module_arg_stor_liquid_m_01" => "liquid",
_ => "container",
};
if (analysis.HasMissingOutputStorage
&& (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass)
|| CommodityUsesStorageClass(world, objectiveCommodityId, storageClass)))
{
unlockScore += 70f;
}
break;
}
return unlockScore * MathF.Max(0.4f, 1f - analysis.Readiness);
}
private static float EstimateImmediateProducerActivationScore(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy,
string moduleId,
string commodityId)
{
var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId);
if (analysis.CanRunSoon)
{
return 110f;
}
if (!analysis.HasMissingFactionInputs && !analysis.HasMissingOutputStorage)
{
return 45f * MathF.Max(0.6f, analysis.WorkforceFactor);
}
return 0f;
}
private static float EstimateConstructionBottleneckImpact(
SimulationWorld world,
string moduleId,
IReadOnlyDictionary<string, float> constructionDemandByItem)
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
{
return 0f;
}
var score = 0f;
foreach (var productItemId in moduleDefinition.Products)
{
if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f)
{
continue;
}
var outputRate = EstimateModuleOutputRate(world, moduleId, productItemId);
if (outputRate <= 0.0001f)
{
continue;
}
score += MathF.Min(outstandingDemand, outputRate * 900f) * 0.8f;
}
return score;
}
private static float EstimateModuleOutputRate(SimulationWorld world, string moduleId, string itemId)
{
var recipe = world.Recipes.Values
.Where(recipe => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal))
.Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal)))
.OrderByDescending(recipe => recipe.Priority)
.FirstOrDefault();
if (recipe is null)
{
return 0f;
}
return recipe.Outputs
.Where(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))
.Sum(output => output.Amount) / MathF.Max(recipe.Duration, 0.01f);
}
private static IReadOnlyDictionary<string, float> GetOutstandingConstructionDemand(SimulationWorld world, string factionId)
{
var demand = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var site in world.ConstructionSites.Where(site =>
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
{
foreach (var required in site.RequiredItems)
{
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
if (remaining <= 0.01f)
{
continue;
}
demand[required.Key] = demand.GetValueOrDefault(required.Key) + remaining;
}
}
return demand;
}
private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass)
{
if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
{
return false;
}
return recipe.Inputs.Any(input =>
world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition)
&& string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal));
}
private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) =>
world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition)
&& string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal);
private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind);
if (capacity <= 0.01f)
{
return false;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && string.Equals(definition.CargoKind, itemDefinition.CargoKind, StringComparison.Ordinal))
.Sum(entry => entry.Value);
return used + amount <= capacity * 0.95f;
}
private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) =>
world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal);
internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{
var nextModuleId = GetNextStationModuleToBuild(station, world);
@@ -223,6 +774,16 @@ internal sealed class InfrastructureSimulationService
}
}
private sealed record ModuleExpansionCandidate(string ModuleId, float Score);
private sealed record ProducerLaneAnalysis(
float Readiness,
float WorkforceFactor,
bool HasMissingLocalInputs,
bool HasMissingFactionInputs,
bool HasMissingOutputStorage,
bool CanRunSoon);
internal static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;

View File

@@ -85,6 +85,7 @@ internal sealed class StationLifecycleService
};
world.Ships.Add(ship);
EnsureSpawnedShipCommander(world, station, ship);
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
{
faction.ShipsBuilt += 1;
@@ -124,4 +125,86 @@ internal sealed class StationLifecycleService
],
};
}
internal static void EnsureStationCommander(SimulationWorld world, StationRuntime station)
{
if (!string.IsNullOrWhiteSpace(station.CommanderId))
{
return;
}
var factionCommander = world.Commanders.FirstOrDefault(candidate =>
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
&& string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal));
var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal));
if (factionCommander is null || faction is null)
{
return;
}
var commander = new CommanderRuntime
{
Id = $"commander-station-{station.Id}",
Kind = CommanderKind.Station,
FactionId = station.FactionId,
ParentCommanderId = factionCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "station-default",
};
station.CommanderId = commander.Id;
station.PolicySetId = factionCommander.PolicySetId;
factionCommander.SubordinateCommanderIds.Add(commander.Id);
faction.CommanderIds.Add(commander.Id);
world.Commanders.Add(commander);
}
private static void EnsureSpawnedShipCommander(SimulationWorld world, StationRuntime station, ShipRuntime ship)
{
var factionCommander = world.Commanders.FirstOrDefault(candidate =>
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
&& string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal));
var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal));
if (factionCommander is null || faction is null)
{
return;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = ship.FactionId,
ParentCommanderId = factionCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = new CommanderBehaviorRuntime
{
Kind = ship.DefaultBehavior.Kind,
AreaSystemId = ship.DefaultBehavior.AreaSystemId,
TargetEntityId = ship.DefaultBehavior.TargetEntityId,
ItemId = ship.DefaultBehavior.ItemId,
StationId = ship.DefaultBehavior.StationId,
ModuleId = ship.DefaultBehavior.ModuleId,
NodeId = ship.DefaultBehavior.NodeId,
Phase = ship.DefaultBehavior.Phase,
PatrolIndex = ship.DefaultBehavior.PatrolIndex,
},
ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Pending,
TargetSystemId = ship.SystemId,
Threshold = 0f,
},
};
ship.CommanderId = commander.Id;
ship.PolicySetId = factionCommander.PolicySetId;
factionCommander.SubordinateCommanderIds.Add(commander.Id);
faction.CommanderIds.Add(commander.Id);
world.Commanders.Add(commander);
}
}

View File

@@ -15,23 +15,54 @@ internal sealed class StationSimulationService
}
var desiredOrders = new List<DesiredMarketOrder>();
var economy = FactionEconomyAnalyzer.Build(world, station.FactionId);
var role = DetermineStationRole(station);
var site = GetConstructionSiteForStation(world, station.Id);
var waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells");
var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts");
var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals");
var iceReserve = role == "water" ? 260f : 0f;
var energyReserve = role switch
{
"power" => 120f,
"refinery" => 160f,
"hullparts" => 180f,
"claytronics" => 220f,
"water" => 140f,
_ => 60f,
} + constructionEnergyReserve;
var refinedReserve = role switch
{
"hullparts" => 220f,
"shipyard" => 260f,
"refinery" => 80f,
_ => 0f,
};
var oreReserve = role == "refinery" ? 260f : 0f;
var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
? 90f
: 0f;
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f);
AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f));
AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f));
AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f));
AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f));
AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f));
AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f));
AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f));
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, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f));
AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f));
AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f));
AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f));
AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f));
AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f));
AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f));
ReconcileStationMarketOrders(world, station, desiredOrders);
}
@@ -112,11 +143,11 @@ internal sealed class StationSimulationService
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
recipe.RequiredModules.FirstOrDefault(moduleId =>
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var laneModuleId = GetStationProductionLaneKey(world, recipe);
if (laneModuleId is null)
@@ -180,7 +211,7 @@ internal sealed class StationSimulationService
};
}
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
@@ -240,6 +271,71 @@ internal sealed class StationSimulationService
private static bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
internal static string NormalizeStationObjective(string? objective)
{
return objective?.Trim().ToLowerInvariant() switch
{
"power" or "energy" or "energycells" => "power",
"water" or "ice-refinery" => "water",
"refinery" or "refinedmetals" => "refinery",
"hullparts" or "hull" => "hullparts",
"claytronics" or "clay" => "claytronics",
"shipyard" or "ship-production" => "shipyard",
_ => "general",
};
}
internal static string DetermineStationRole(StationRuntime station)
{
var objective = NormalizeStationObjective(station.Objective);
if (!string.Equals(objective, "general", StringComparison.Ordinal))
{
return objective;
}
if (HasStationModules(station, "module_gen_build_l_01"))
{
return "shipyard";
}
if (HasStationModules(station, "module_gen_prod_water_01"))
{
return "water";
}
if (HasStationModules(station, "module_gen_prod_claytronics_01"))
{
return "claytronics";
}
if (HasStationModules(station, "module_gen_prod_hullparts_01"))
{
return "hullparts";
}
if (HasStationModules(station, "module_gen_prod_refinedmetals_01"))
{
return "refinery";
}
if (HasStationModules(station, "module_gen_prod_energycells_01"))
{
return "power";
}
return "general";
}
private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId)
{
if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required))
{
return 0f;
}
return MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, itemId));
}
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
{
var current = GetInventoryAmount(station.Inventory, itemId);
@@ -267,7 +363,9 @@ internal sealed class StationSimulationService
return;
}
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
var surplusRatio = triggerAmount <= 0.01f ? 1f : MathF.Min(1f, surplus / triggerAmount);
var liquidationValuation = MathF.Max(0.05f, valuationBase * (1f - (0.85f * surplusRatio)));
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, liquidationValuation, reserveFloor));
}
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
@@ -330,6 +428,50 @@ internal sealed class StationSimulationService
return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id));
}
private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve)
{
var commodity = economy.GetCommodity(itemId);
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
{
return MathF.Max(0f, baseReserve);
}
return commodity.ShortageHorizonSeconds < 180f
? baseReserve * 1.5f
: commodity.ShortageHorizonSeconds < 360f
? baseReserve * 1.2f
: baseReserve;
}
private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger)
{
var commodity = economy.GetCommodity(itemId);
return commodity.NetRatePerSecond < -0.01f ? baseTrigger * 1.2f : baseTrigger;
}
private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
{
var commodity = economy.GetCommodity(itemId);
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
{
return commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.3f;
}
return commodity.ShortageHorizonSeconds < 180f
? baseValuation * 1.5f
: commodity.ShortageHorizonSeconds < 360f
? baseValuation * 1.25f
: baseValuation;
}
private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
{
var commodity = economy.GetCommodity(itemId);
return commodity.NetRatePerSecond > 0.01f && commodity.ShortageHorizonSeconds > 600f
? baseValuation * 0.75f
: baseValuation;
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var totalLagrangePoints = world.Celestials.Count(node =>

View File

@@ -22,6 +22,7 @@ public sealed class SimulationWorld
public required Dictionary<string, ModuleDefinition> ModuleDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public required ProductionGraph ProductionGraph { get; init; }
public int TickIntervalMs { get; init; } = 200;
public double OrbitalTimeSeconds { get; set; }
public DateTimeOffset GeneratedAtUtc { get; set; }

View File

@@ -20,6 +20,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
var balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules);
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
return new ScenarioCatalog(
authoredSystems,
@@ -29,7 +30,8 @@ internal sealed class DataCatalogLoader(string dataRoot)
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal));
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal),
productionGraph);
}
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
@@ -56,6 +58,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
Objective = station.Objective,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
@@ -71,6 +74,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
@@ -297,4 +301,5 @@ internal sealed record ScenarioCatalog(
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes);
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
ProductionGraph ProductionGraph);

View File

@@ -39,7 +39,7 @@ internal sealed class WorldBuilder(
seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, refinery);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations);
@@ -47,7 +47,32 @@ internal sealed class WorldBuilder(
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(stations, claims, catalog.ModuleRecipes);
var bootstrapWorld = new SimulationWorld
{
Label = "Split Viewer / Bootstrap World",
Seed = WorldSeed,
Balance = catalog.Balance,
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Stations = stations,
Ships = ships,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = [],
MarketOrders = [],
Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
ProductionGraph = catalog.ProductionGraph,
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = nowUtc,
};
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld);
return new SimulationWorld
{
@@ -70,6 +95,7 @@ internal sealed class WorldBuilder(
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
ProductionGraph = catalog.ProductionGraph,
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
@@ -99,9 +125,12 @@ internal sealed class WorldBuilder(
SystemId = system.Definition.Id,
Label = plan.Label,
Color = plan.Color,
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
CelestialId = placement.AnchorCelestial.Id,
Health = 600f,
MaxHealth = 600f,
};
stations.Add(station);
@@ -142,6 +171,7 @@ internal sealed class WorldBuilder(
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery)
{
var ships = new List<ShipRuntime>();
@@ -168,10 +198,25 @@ internal sealed class WorldBuilder(
Position = position,
TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = WorldSeedingService.CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
DefaultBehavior = WorldSeedingService.CreateBehavior(
definition,
formation.SystemId,
formation.FactionId ?? DefaultFactionId,
scenario,
patrolRoutes,
stations,
refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth,
});
foreach (var (itemId, amount) in formation.StartingInventory)
{
if (amount > 0f)
{
ships[^1].Inventory[itemId] = amount;
}
}
}
}

View File

@@ -37,7 +37,7 @@ internal sealed class WorldSeedingService
.ToList();
var refineries = ownedStations
.Where(station => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"))
.Where(station => string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal))
.ToList();
if (refineries.Count > 0)
@@ -65,10 +65,32 @@ internal sealed class WorldSeedingService
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refinedmetals"] = 120f;
if (station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal))
{
station.Inventory["energycells"] = MathF.Max(GetInventoryAmount(station.Inventory, "energycells"), 240f);
}
if (station.InstalledModules.Contains("module_gen_prod_refinedmetals_01", StringComparer.Ordinal))
{
station.Inventory["ore"] = MathF.Max(GetInventoryAmount(station.Inventory, "ore"), 220f);
}
if (station.InstalledModules.Contains("module_gen_prod_hullparts_01", StringComparer.Ordinal))
{
station.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(station.Inventory, "refinedmetals"), 240f);
station.Inventory["graphene"] = MathF.Max(GetInventoryAmount(station.Inventory, "graphene"), 80f);
}
if (station.InstalledModules.Contains("module_gen_prod_claytronics_01", StringComparer.Ordinal))
{
station.Inventory["antimattercells"] = MathF.Max(GetInventoryAmount(station.Inventory, "antimattercells"), 90f);
station.Inventory["microchips"] = MathF.Max(GetInventoryAmount(station.Inventory, "microchips"), 120f);
station.Inventory["quantumtubes"] = MathF.Max(GetInventoryAmount(station.Inventory, "quantumtubes"), 90f);
}
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f);
}
}
}
@@ -76,10 +98,10 @@ internal sealed class WorldSeedingService
internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{
return stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal));
}
internal List<ClaimRuntime> CreateClaims(
@@ -116,23 +138,21 @@ internal sealed class WorldSeedingService
}
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
SimulationWorld world)
{
var sites = new List<ConstructionSiteRuntime>();
var orders = new List<MarketOrderRuntime>();
foreach (var station in stations)
foreach (var station in world.Stations)
{
var moduleId = GetNextConstructionSiteModule(station, moduleRecipes);
var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world);
if (moduleId is null || station.CelestialId is null)
{
continue;
}
var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
{
continue;
}
@@ -294,23 +314,40 @@ internal sealed class WorldSeedingService
internal static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
string factionId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery)
{
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && refinery is not null)
var homeStation = stations.FirstOrDefault(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
?? refinery;
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = refinery.Id,
StationId = homeStation.Id,
Phase = "travel-to-station",
};
}
if (HasCapabilities(definition, "mining") && refinery is not null)
if (HasCapabilities(definition, "mining") && homeStation is not null)
{
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, homeStation.Id);
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime
{
Kind = "trade-haul",
Phase = "travel-to-source",
};
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
@@ -318,6 +355,7 @@ internal sealed class WorldSeedingService
return new DefaultBehaviorRuntime
{
Kind = "patrol",
StationId = homeStation?.Id,
PatrolPoints = route,
PatrolIndex = 0,
};
@@ -340,6 +378,13 @@ internal sealed class WorldSeedingService
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
"asterion-league" => new FactionRuntime
{
Id = factionId,
Label = "Asterion League",
Color = "#ff8f70",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
@@ -350,31 +395,6 @@ internal sealed class WorldSeedingService
};
}
private static string? GetNextConstructionSiteModule(
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
@@ -406,6 +426,8 @@ internal sealed class WorldSeedingService
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
TargetEntityId = behavior.TargetEntityId,
ItemId = behavior.ItemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,

View File

@@ -6,7 +6,7 @@
}
},
"WorldGeneration": {
"TargetSystemCount": 1,
"TargetSystemCount": 3,
"IncludeSolSystem": true
},
"OrbitalSimulation": {

View File

@@ -1,50 +1,138 @@
{
"initialStations": [
{
"label": "Orbital Station",
"label": "Dominion Power Relay",
"color": "#7ed4ff",
"objective": "power",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_gen_prod_energycells_01",
"module_arg_stor_solid_m_01",
"module_arg_stor_liquid_m_01",
"module_arg_stor_container_m_01",
"module_gen_prod_refinedmetals_01"
"module_gen_prod_energycells_01"
],
"systemId": "helios",
"factionId": "sol-dominion",
"planetIndex": 1,
"lagrangeSide": -1
},
{
"label": "Dominion Hullworks",
"color": "#7ed4ff",
"objective": "hullparts",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_arg_stor_container_m_01",
"module_gen_prod_energycells_01",
"module_gen_prod_hullparts_01"
],
"systemId": "helios",
"factionId": "sol-dominion",
"planetIndex": 2,
"lagrangeSide": -1
},
{
"label": "Dominion Clay Grid",
"color": "#7ed4ff",
"objective": "claytronics",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_arg_stor_container_m_01",
"module_gen_prod_energycells_01",
"module_gen_prod_claytronics_01"
],
"systemId": "helios",
"factionId": "sol-dominion",
"planetIndex": 0,
"lagrangeSide": 1
},
{
"label": "League Power Relay",
"color": "#ff8f70",
"objective": "power",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_arg_stor_liquid_m_01",
"module_arg_stor_container_m_01",
"module_gen_prod_energycells_01"
],
"systemId": "sol",
"factionId": "asterion-league",
"planetIndex": 1,
"lagrangeSide": 1
},
{
"label": "League Hullworks",
"color": "#ff8f70",
"objective": "hullparts",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_arg_stor_container_m_01",
"module_gen_prod_energycells_01",
"module_gen_prod_hullparts_01"
],
"systemId": "sol",
"factionId": "asterion-league",
"planetIndex": 2,
"lagrangeSide": 1
},
{
"label": "League Clay Grid",
"color": "#ff8f70",
"objective": "claytronics",
"startingModules": [
"module_arg_dock_m_01_lowtech",
"module_arg_stor_container_m_01",
"module_gen_prod_energycells_01",
"module_gen_prod_claytronics_01"
],
"systemId": "sol",
"factionId": "asterion-league",
"planetIndex": 3,
"lagrangeSide": -1
}
],
"shipFormations": [
{
"shipId": "constructor",
"count": 1,
"center": [
45,
0,
20
],
"systemId": "helios"
"center": [ 40, 0, 12 ],
"systemId": "helios",
"factionId": "sol-dominion"
},
{
"shipId": "miner",
"count": 1,
"center": [
52,
0,
24
],
"systemId": "helios"
"center": [ 54, 0, 18 ],
"systemId": "helios",
"factionId": "sol-dominion"
},
{
"shipId": "hauler",
"count": 1,
"center": [
60,
0,
28
],
"systemId": "helios"
"center": [ 62, 0, 8 ],
"systemId": "helios",
"factionId": "sol-dominion"
},
{
"shipId": "constructor",
"count": 1,
"center": [ 42, 0, -16 ],
"systemId": "sol",
"factionId": "asterion-league"
},
{
"shipId": "miner",
"count": 1,
"center": [ 56, 0, -12 ],
"systemId": "sol",
"factionId": "asterion-league"
},
{
"shipId": "hauler",
"count": 1,
"center": [ 68, 0, -18 ],
"systemId": "sol",
"factionId": "asterion-league"
}
],
"patrolRoutes": [],

View File

@@ -319,8 +319,7 @@
"warpSpeed": 0.13,
"ftlSpeed": 0.48,
"spoolTime": 3.5,
"cargoCapacity": 160,
"cargoKind": "manufactured",
"cargoCapacity": 0,
"color": "#9af0c1",
"hullColor": "#2d5d47",
"size": 9,

View File

@@ -423,23 +423,44 @@
{
"angle": 0.45,
"radiusOffset": 180000,
"oreAmount": 3000,
"oreAmount": 12000,
"itemId": "ore",
"shardCount": 7
},
{
"angle": 2.544395102,
"radiusOffset": 180000,
"oreAmount": 3000,
"oreAmount": 12000,
"itemId": "ore",
"shardCount": 7
},
{
"angle": 4.638790205,
"radiusOffset": 180000,
"oreAmount": 3000,
"oreAmount": 12000,
"itemId": "ore",
"shardCount": 7
},
{
"angle": 1.2,
"radiusOffset": 235000,
"oreAmount": 14000,
"itemId": "ore",
"shardCount": 9
},
{
"angle": 3.05,
"radiusOffset": 228000,
"oreAmount": 14000,
"itemId": "ore",
"shardCount": 9
},
{
"angle": 5.25,
"radiusOffset": 242000,
"oreAmount": 14000,
"itemId": "ore",
"shardCount": 9
}
],
"planets": [