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

View File

@@ -12,17 +12,22 @@ internal sealed class CommanderPlanningService
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals = private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
[ [
new ExterminateRivalGoal(),
new EnsureWarIndustryGoal(),
new ExpandTerritoryGoal(), new ExpandTerritoryGoal(),
new EnsureWarFleetGoal(), new EnsureWarFleetGoal(),
new EnsureWaterSecurityGoal(),
new EnsureMiningCapacityGoal(), new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(), new EnsureConstructionCapacityGoal(),
]; ];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions = private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[ [
new SetAttackObjectiveAction(),
new SetMiningObjectiveAction(), new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(), new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(), new SetConstructionObjectiveAction(),
new SetTradeObjectiveAction(),
new SetIdleObjectiveAction(), new SetIdleObjectiveAction(),
]; ];
@@ -93,6 +98,19 @@ internal sealed class CommanderPlanningService
var plan = _factionPlanner.Plan(state, goal, actions); var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(engine, world, commander); 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) private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
@@ -124,9 +142,20 @@ internal sealed class CommanderPlanningService
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId) internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
{ {
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList(); 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 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 => MilitaryShipCount = world.Ships.Count(s =>
s.FactionId == factionId && s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)), string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
@@ -142,8 +171,19 @@ internal sealed class CommanderPlanningService
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId), ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)), 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)), HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")), OreStockpile = economy.GetCommodity("ore").OnHand,
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")), 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 && c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); 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 return new ShipPlanningState
{ {
ShipKind = ship.Definition.Kind, ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"), HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true, FactionWantsOre = true,
FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false,
FactionWantsExpansion = factionCommander?.ActiveDirectives FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false, .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>>(); var actions = new List<GoapAction<FactionPlanningState>>();
actions.Add(new PlanWarIndustryAction());
actions.Add(new PlanCommoditySupplyAction("water"));
foreach (var (shipId, def) in world.ShipDefinitions) foreach (var (shipId, def) in world.ShipDefinitions)
{ {
actions.Add(new OrderShipProductionAction(def.Kind, shipId)); actions.Add(new OrderShipProductionAction(def.Kind, shipId));
} }
actions.Add(new LaunchExterminationCampaignAction());
actions.Add(new ExpandToSystemAction()); actions.Add(new ExpandToSystemAction());
return actions; return actions;
} }
@@ -184,4 +267,137 @@ internal sealed class CommanderPlanningService
c.FactionId == factionId && c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)) string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false; ?.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 ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; } public int TargetSystemCount { get; set; }
public bool HasShipFactory { 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 OreStockpile { get; set; }
public float RefinedMetalsStockpile { 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(); public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
internal static int ComputeTargetWarships(FactionPlanningState state) internal static int ComputeTargetWarships(FactionPlanningState state)
{ {
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount); 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 ───────────────────────────────────────────────────────────────────── // ─── 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 sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
{ {
public override string Name => "ensure-war-fleet"; 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 sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{ {
public override string Name => "expand-territory"; 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 string Name => $"order-{shipId}-production";
public override float Cost => 1f; 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) 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 sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
{ {
public override string Name => "expand-to-system"; public override string Name => "expand-to-system";
public override float Cost => 3f; public override float Cost => 3f;
public override bool CheckPreconditions(FactionPlanningState state) => 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) public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{ {
@@ -140,3 +301,28 @@ public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
commander.ActiveDirectives.Add("expand-territory"); 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, int TargetSystemCount,
bool HasShipFactory, bool HasShipFactory,
float OreStockpile, 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); 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 required string Kind { get; set; }
public string? Phase { get; set; } public string? Phase { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? NodeId { get; set; } public string? NodeId { get; set; }
public string? StationId { get; set; } public string? StationId { get; set; }
public string? ModuleId { 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.AI;
global using SpaceGame.Api.Factions.Contracts; global using SpaceGame.Api.Factions.Contracts;
global using SpaceGame.Api.Factions.Runtime; global using SpaceGame.Api.Factions.Runtime;
global using SpaceGame.Api.Industry.Planning;
global using SpaceGame.Api.Shared.AI; global using SpaceGame.Api.Shared.AI;
global using SpaceGame.Api.Shared.Contracts; global using SpaceGame.Api.Shared.Contracts;
global using SpaceGame.Api.Shared.Runtime; 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, DeliveringConstruction,
Blocked, Blocked,
Undocking, Undocking,
EngagingTarget,
} }
public enum ControllerTaskKind public enum ControllerTaskKind
@@ -60,6 +61,7 @@ public enum ControllerTaskKind
Unload, Unload,
DeliverConstruction, DeliverConstruction,
BuildConstructionSite, BuildConstructionSite,
AttackTarget,
ConstructModule, ConstructModule,
Undock, Undock,
@@ -210,6 +212,7 @@ public static class SimulationEnumMappings
ShipState.DeliveringConstruction => "delivering-construction", ShipState.DeliveringConstruction => "delivering-construction",
ShipState.Blocked => "blocked", ShipState.Blocked => "blocked",
ShipState.Undocking => "undocking", ShipState.Undocking => "undocking",
ShipState.EngagingTarget => "engaging-target",
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null), _ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
}; };
@@ -223,6 +226,7 @@ public static class SimulationEnumMappings
ControllerTaskKind.Unload => "unload", ControllerTaskKind.Unload => "unload",
ControllerTaskKind.DeliverConstruction => "deliver-construction", ControllerTaskKind.DeliverConstruction => "deliver-construction",
ControllerTaskKind.BuildConstructionSite => "build-construction-site", ControllerTaskKind.BuildConstructionSite => "build-construction-site",
ControllerTaskKind.AttackTarget => "attack-target",
ControllerTaskKind.ConstructModule => "construct-module", ControllerTaskKind.ConstructModule => "construct-module",
ControllerTaskKind.Undock => "undock", ControllerTaskKind.Undock => "undock",

View File

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

View File

@@ -93,6 +93,9 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
case ("dock", "docked"): case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload"; ship.DefaultBehavior.Phase = "unload";
break; break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"): case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null; 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 HasMiningCapability { get; set; }
public bool FactionWantsOre { get; set; } public bool FactionWantsOre { get; set; }
public bool FactionWantsExpansion { 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 string? CurrentObjective { get; set; }
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone(); 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 sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
{ {
public override string Name => "set-construction-objective"; public override string Name => "set-construction-objective";
public override float Cost => 1f; public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) => 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) 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 sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
{ {
public override string Name => "set-idle-objective"; public override string Name => "set-idle-objective";

View File

@@ -42,6 +42,8 @@ public sealed class DefaultBehaviorRuntime
{ {
public required string Kind { get; set; } public required string Kind { get; set; }
public string? AreaSystemId { get; set; } public string? AreaSystemId { get; set; }
public string? TargetEntityId { get; set; }
public string? ItemId { get; set; }
public string? StationId { get; set; } public string? StationId { get; set; }
public string? RefineryId { get; set; } public string? RefineryId { get; set; }
public string? NodeId { 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.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId; 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.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId; ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase; ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
@@ -61,6 +63,8 @@ internal sealed class ShipControlService
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind }; commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind; commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId; 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.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId; commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase; commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
@@ -140,24 +144,193 @@ internal sealed class ShipControlService
SyncCommanderTask(commander, ship.ControllerTask); 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) internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{ {
var behavior = ship.DefaultBehavior; 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; behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null var node = behavior.NodeId is null
? world.Nodes ? world.Nodes
.Where(candidate => .Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == targetResourceItemId &&
candidate.ItemId == resourceItemId && candidate.OreRemaining > 0.01f &&
candidate.OreRemaining > 0.01f) CanShipMineItem(world, ship, candidate.ItemId))
.OrderByDescending(candidate => candidate.OreRemaining) .OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
.ThenByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault() .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)) 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); ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return; 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) internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{ {
var preferred = preferredStationId is null var preferred = preferredStationId is null
@@ -267,7 +489,8 @@ internal sealed class ShipControlService
order.ItemId == itemId && order.ItemId == itemId &&
order.RemainingAmount > 0.01f) order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId))) .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 => .OrderByDescending(entry =>
{ {
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
@@ -275,7 +498,18 @@ internal sealed class ShipControlService
}) })
.FirstOrDefault(); .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) => 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 behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); 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) if (station is null)
{ {
behavior.Kind = "idle"; behavior.Kind = "idle";
@@ -328,6 +564,13 @@ internal sealed class ShipControlService
return; 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); var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId; behavior.ModuleId = moduleId;
if (moduleId is null) if (moduleId is null)
@@ -347,13 +590,17 @@ internal sealed class ShipControlService
ship.DockedStationId = null; ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null; ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id); ship.Position = ResolveConstructionHoldPosition(ship, station, site, world);
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
} }
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id); var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
var isAtConstructionHold = ship.SystemId == station.SystemId var targetSystemId = site?.SystemId ?? station.SystemId;
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f; 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) if (isAtConstructionHold)
{ {
@@ -390,7 +637,7 @@ internal sealed class ShipControlService
{ {
Kind = ControllerTaskKind.ConstructModule, Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition, TargetPosition = constructionHoldPosition,
Threshold = 10f, Threshold = 10f,
}; };
@@ -400,7 +647,7 @@ internal sealed class ShipControlService
{ {
Kind = ControllerTaskKind.DeliverConstruction, Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id, TargetEntityId = site?.Id,
TargetSystemId = station.SystemId, TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition, TargetPosition = constructionHoldPosition,
Threshold = 10f, Threshold = 10f,
}; };
@@ -410,7 +657,7 @@ internal sealed class ShipControlService
{ {
Kind = ControllerTaskKind.BuildConstructionSite, Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id, TargetEntityId = site?.Id,
TargetSystemId = station.SystemId, TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition, TargetPosition = constructionHoldPosition,
Threshold = 10f, Threshold = 10f,
}; };
@@ -419,8 +666,8 @@ internal sealed class ShipControlService
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = ControllerTaskKind.Idle, Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id, TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = station.SystemId, TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 0f,
}; };
@@ -429,8 +676,8 @@ internal sealed class ShipControlService
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = ControllerTaskKind.Travel, Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id, TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = station.SystemId, TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition, TargetPosition = constructionHoldPosition,
Threshold = 10f, Threshold = 10f,
}; };
@@ -539,6 +786,7 @@ internal sealed class ShipControlService
"unload" => ControllerTaskKind.Unload, "unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction, "deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite, "build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"attack-target" => ControllerTaskKind.AttackTarget,
"construct-module" => ControllerTaskKind.ConstructModule, "construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock, "undock" => ControllerTaskKind.Undock,
@@ -563,4 +811,62 @@ internal sealed class ShipControlService
Threshold = task.Threshold, 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)); BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId); var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
var transferredAny = false;
foreach (var (itemId, amount) in ship.Inventory.ToList()) foreach (var (itemId, amount) in ship.Inventory.ToList())
{ {
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds); var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved); var accepted = TryAddStationInventory(world, station, itemId, moved);
transferredAny |= accepted > 0.01f;
RemoveInventory(ship.Inventory, itemId, accepted); RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal)) 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"; return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
} }
@@ -239,7 +247,7 @@ internal sealed partial class ShipTaskExecutionService
return "none"; return "none";
} }
var supportPosition = ResolveShipSupportPosition(ship, station); var supportPosition = ResolveShipSupportPosition(ship, station, null, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{ {
ship.State = ShipState.LocalFlight; ship.State = ShipState.LocalFlight;
@@ -296,7 +304,7 @@ internal sealed partial class ShipTaskExecutionService
return "none"; return "none";
} }
var supportPosition = ResolveShipSupportPosition(ship, station); var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{ {
ship.State = ShipState.LocalFlight; ship.State = ShipState.LocalFlight;
@@ -313,6 +321,28 @@ internal sealed partial class ShipTaskExecutionService
if (site.StationId is not null) 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"; return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
} }
@@ -359,7 +389,7 @@ internal sealed partial class ShipTaskExecutionService
return "none"; return "none";
} }
var supportPosition = ResolveShipSupportPosition(ship, station); var supportPosition = ResolveShipSupportPosition(ship, station, site, world);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{ {
ship.State = ShipState.LocalFlight; ship.State = ShipState.LocalFlight;
@@ -386,8 +416,16 @@ internal sealed partial class ShipTaskExecutionService
return "none"; return "none";
} }
AddStationModule(world, station, site.BlueprintId); if (site.StationId is null)
PrepareNextConstructionSiteStep(world, station, site); {
CompleteStationFoundation(world, station, site);
}
else
{
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
}
return "site-constructed"; return "site-constructed";
} }
@@ -398,10 +436,21 @@ internal sealed partial class ShipTaskExecutionService
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null; : null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) => private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
ship.DockedStationId is not null {
? GetShipDockedPosition(ship, station) if (ship.DockedStationId is not null)
: GetConstructionHoldPosition(station, ship.Id); {
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) => private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); 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) => internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key))); 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 internal sealed partial class ShipTaskExecutionService
{ {
private const float WarpEngageDistanceKilometers = 250_000f; 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) => private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed); SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
@@ -30,6 +34,7 @@ internal sealed partial class ShipTaskExecutionService
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds), ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds), ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds), ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds), ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(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) private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
var task = ship.ControllerTask; 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) if (task.TargetPosition is null || task.TargetSystemId is null)
{ {
ship.State = ShipState.Idle; ship.State = ShipState.Idle;
@@ -94,6 +104,66 @@ internal sealed partial class ShipTaskExecutionService
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold); 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) private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{ {
if (!string.IsNullOrWhiteSpace(task.TargetEntityId)) if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
@@ -309,4 +379,14 @@ internal sealed partial class ShipTaskExecutionService
ship.State = ShipState.Arriving; ship.State = ShipState.Arriving;
return "none"; 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); _commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events);
_stationLifecycle.UpdateStations(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 previousPosition = ship.Position;
var previousState = ship.State; var previousState = ship.State;
var previousBehavior = ship.DefaultBehavior.Kind; var previousBehavior = ship.DefaultBehavior.Kind;
@@ -58,6 +63,7 @@ public sealed class SimulationEngine
} }
_orbitalStateUpdater.SyncSpatialState(world); _orbitalStateUpdater.SyncSpatialState(world);
CleanupDestroyedEntities(world, events);
world.GeneratedAtUtc = nowUtc; world.GeneratedAtUtc = nowUtc;
return _projection.BuildDelta(world, sequence, events); return _projection.BuildDelta(world, sequence, events);
@@ -75,6 +81,60 @@ public sealed class SimulationEngine
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) => internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) =>
_shipControl.PlanStationConstruction(ship, 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) => internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(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.Id,
station.Label, station.Label,
station.Category, station.Category,
station.Objective,
station.SystemId, station.SystemId,
station.LocalPosition, station.LocalPosition,
station.CelestialId, station.CelestialId,
@@ -544,6 +545,7 @@ internal sealed class SimulationProjectionService
station.Id, station.Id,
station.Label, station.Label,
station.Category, station.Category,
station.Objective,
station.SystemId, station.SystemId,
ToDto(station.Position), ToDto(station.Position),
station.CelestialId, station.CelestialId,
@@ -770,8 +772,16 @@ internal sealed class SimulationProjectionService
ps.ControlledSystemCount, ps.ControlledSystemCount,
ps.TargetSystemCount, ps.TargetSystemCount,
ps.HasShipFactory, ps.HasShipFactory,
ps.OreStockpile, NormalizeFiniteFloat(ps.OreStockpile),
ps.RefinedMetalsStockpile); 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) if (commander?.LastGoalPriorities is { } prios)
@@ -811,4 +821,7 @@ internal sealed class SimulationProjectionService
state.Transit.Progress)); state.Transit.Progress));
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); 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 Id,
string Label, string Label,
string Category, string Category,
string Objective,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId, string? CelestialId,
@@ -32,6 +33,7 @@ public sealed record StationDelta(
string Id, string Id,
string Label, string Label,
string Category, string Category,
string Objective,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? CelestialId, string? CelestialId,

View File

@@ -6,6 +6,7 @@ public sealed class StationRuntime
public required string SystemId { get; init; } public required string SystemId { get; init; }
public required string Label { get; set; } public required string Label { get; set; }
public string Category { get; set; } = "station"; public string Category { get; set; } = "station";
public string Objective { get; set; } = "general";
public string Color { get; set; } = "#8df0d2"; public string Color { get; set; } = "#8df0d2";
public required Vector3 Position { get; set; } public required Vector3 Position { get; set; }
public float Radius { get; set; } = 24f; public float Radius { get; set; } = 24f;
@@ -14,6 +15,8 @@ public sealed class StationRuntime
public string? CommanderId { get; set; } public string? CommanderId { get; set; }
public string? PolicySetId { get; set; } public string? PolicySetId { get; set; }
public List<StationModuleRuntime> Modules { get; } = []; 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 IEnumerable<string> InstalledModules => Modules.Select((module) => module.ModuleId);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal); public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> ProductionLaneTimers { 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) internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{ {
// Expand storage before it becomes a bottleneck var economy = FactionEconomyAnalyzer.Build(world, station.FactionId);
const float StorageExpansionThreshold = 0.85f; return GetModuleExpansionCandidates(world, station, economy)
var storageExpansionCandidates = new[] .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"), ("solid", "module_arg_stor_solid_m_01"),
("liquid", "module_arg_stor_liquid_m_01"), ("liquid", "module_arg_stor_liquid_m_01"),
("container", "module_arg_stor_container_m_01"), ("container", "module_arg_stor_container_m_01"),
}; })
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
{ {
var capacity = GetStationStorageCapacity(station, storageClass); var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f) if (capacity <= 0.01f)
@@ -123,51 +173,552 @@ internal sealed class InfrastructureSimulationService
} }
var used = station.Inventory var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value); .Sum(entry => entry.Value);
if (used / capacity >= 0.65f)
if (used / capacity >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId))
{ {
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) internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{ {
var nextModuleId = GetNextStationModuleToBuild(station, world); 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) => internal static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2; CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;

View File

@@ -85,6 +85,7 @@ internal sealed class StationLifecycleService
}; };
world.Ships.Add(ship); world.Ships.Add(ship);
EnsureSpawnedShipCommander(world, station, ship);
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
{ {
faction.ShipsBuilt += 1; 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 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 waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f; var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells");
var oreReserve = HasRefineryCapability(station) ? 180f : 0f; var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts");
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01") var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics");
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") 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") && FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
? 90f ? 90f
: 0f; : 0f;
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f); AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f));
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f); AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f));
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f); AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f));
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f); 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, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f));
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f); AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f));
AddSupplyOrder(desiredOrders, station, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f); 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); ReconcileStationMarketOrders(world, station, desiredOrders);
} }
@@ -112,11 +143,11 @@ internal sealed class StationSimulationService
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
.FirstOrDefault(recipe => CanRunRecipe(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 => recipe.RequiredModules.FirstOrDefault(moduleId =>
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode)); 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); var laneModuleId = GetStationProductionLaneKey(world, recipe);
if (laneModuleId is null) 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) var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal) || string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
@@ -240,6 +271,71 @@ internal sealed class StationSimulationService
private static bool HasRefineryCapability(StationRuntime station) => private static bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01"); 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) private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
{ {
var current = GetInventoryAmount(station.Inventory, itemId); var current = GetInventoryAmount(station.Inventory, itemId);
@@ -267,7 +363,9 @@ internal sealed class StationSimulationService
return; 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) 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)); 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) private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{ {
var totalLagrangePoints = world.Celestials.Count(node => 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, ModuleDefinition> ModuleDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; } public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; } public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public required ProductionGraph ProductionGraph { get; init; }
public int TickIntervalMs { get; init; } = 200; public int TickIntervalMs { get; init; } = 200;
public double OrbitalTimeSeconds { get; set; } public double OrbitalTimeSeconds { get; set; }
public DateTimeOffset GeneratedAtUtc { 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 balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships, modules); var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules); var moduleRecipes = BuildModuleRecipes(modules);
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
return new ScenarioCatalog( return new ScenarioCatalog(
authoredSystems, authoredSystems,
@@ -29,7 +30,8 @@ internal sealed class DataCatalogLoader(string dataRoot)
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
recipes.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( internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
@@ -56,6 +58,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
SystemId = ResolveSystemId(station.SystemId), SystemId = ResolveSystemId(station.SystemId),
Label = station.Label, Label = station.Label,
Color = station.Color, Color = station.Color,
Objective = station.Objective,
StartingModules = station.StartingModules.ToList(), StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId, FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex, PlanetIndex = station.PlanetIndex,
@@ -71,6 +74,7 @@ internal sealed class DataCatalogLoader(string dataRoot)
Center = formation.Center.ToArray(), Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId), SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId, FactionId = formation.FactionId,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
}) })
.ToList(), .ToList(),
PatrolRoutes = scenario.PatrolRoutes PatrolRoutes = scenario.PatrolRoutes
@@ -297,4 +301,5 @@ internal sealed record ScenarioCatalog(
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions, IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions, IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes, 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); seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario); var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); 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); var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations); seedingService.BootstrapFactionEconomy(factions, stations);
@@ -47,7 +47,32 @@ internal sealed class WorldBuilder(
var commanders = seedingService.CreateCommanders(factions, stations, ships); var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); 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 return new SimulationWorld
{ {
@@ -70,6 +95,7 @@ internal sealed class WorldBuilder(
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal), ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal), ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal), Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
ProductionGraph = catalog.ProductionGraph,
OrbitalTimeSeconds = WorldSeed * 97d, OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow, GeneratedAtUtc = DateTimeOffset.UtcNow,
}; };
@@ -99,9 +125,12 @@ internal sealed class WorldBuilder(
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Label = plan.Label, Label = plan.Label,
Color = plan.Color, Color = plan.Color,
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
Position = placement.Position, Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId, FactionId = plan.FactionId ?? DefaultFactionId,
CelestialId = placement.AnchorCelestial.Id, CelestialId = placement.AnchorCelestial.Id,
Health = 600f,
MaxHealth = 600f,
}; };
stations.Add(station); stations.Add(station);
@@ -142,6 +171,7 @@ internal sealed class WorldBuilder(
BalanceDefinition balance, BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions, IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery) StationRuntime? refinery)
{ {
var ships = new List<ShipRuntime>(); var ships = new List<ShipRuntime>();
@@ -168,10 +198,25 @@ internal sealed class WorldBuilder(
Position = position, Position = position,
TargetPosition = position, TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), 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 }, ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth, 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(); .ToList();
var refineries = ownedStations 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(); .ToList();
if (refineries.Count > 0) if (refineries.Count > 0)
@@ -65,10 +65,32 @@ internal sealed class WorldSeedingService
foreach (var station in stations) foreach (var station in stations)
{ {
InitializeStationPopulation(station); 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) 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) internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{ {
return stations.FirstOrDefault(station => 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) station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station => ?? 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( internal List<ClaimRuntime> CreateClaims(
@@ -116,23 +138,21 @@ internal sealed class WorldSeedingService
} }
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites( internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations, SimulationWorld world)
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{ {
var sites = new List<ConstructionSiteRuntime>(); var sites = new List<ConstructionSiteRuntime>();
var orders = new List<MarketOrderRuntime>(); 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) if (moduleId is null || station.CelestialId is null)
{ {
continue; continue;
} }
var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe))
{ {
continue; continue;
} }
@@ -294,23 +314,40 @@ internal sealed class WorldSeedingService
internal static DefaultBehaviorRuntime CreateBehavior( internal static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition, ShipDefinition definition,
string systemId, string systemId,
string factionId,
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery) 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 return new DefaultBehaviorRuntime
{ {
Kind = "construct-station", Kind = "construct-station",
StationId = refinery.Id, StationId = homeStation.Id,
Phase = "travel-to-station", 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)) 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 return new DefaultBehaviorRuntime
{ {
Kind = "patrol", Kind = "patrol",
StationId = homeStation?.Id,
PatrolPoints = route, PatrolPoints = route,
PatrolIndex = 0, PatrolIndex = 0,
}; };
@@ -340,6 +378,13 @@ internal sealed class WorldSeedingService
Color = "#7ed4ff", Color = "#7ed4ff",
Credits = MinimumFactionCredits, Credits = MinimumFactionCredits,
}, },
"asterion-league" => new FactionRuntime
{
Id = factionId,
Label = "Asterion League",
Color = "#ff8f70",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime _ => new FactionRuntime
{ {
Id = factionId, 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) private static void InitializeStationPopulation(StationRuntime station)
{ {
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01"); var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
@@ -406,6 +426,8 @@ internal sealed class WorldSeedingService
{ {
Kind = behavior.Kind, Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId, AreaSystemId = behavior.AreaSystemId,
TargetEntityId = behavior.TargetEntityId,
ItemId = behavior.ItemId,
ModuleId = behavior.ModuleId, ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId, NodeId = behavior.NodeId,
Phase = behavior.Phase, Phase = behavior.Phase,

View File

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

View File

@@ -1,50 +1,138 @@
{ {
"initialStations": [ "initialStations": [
{ {
"label": "Orbital Station", "label": "Dominion Power Relay",
"color": "#7ed4ff",
"objective": "power",
"startingModules": [ "startingModules": [
"module_arg_dock_m_01_lowtech", "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_liquid_m_01",
"module_arg_stor_container_m_01", "module_arg_stor_container_m_01",
"module_gen_prod_refinedmetals_01" "module_gen_prod_energycells_01"
], ],
"systemId": "helios", "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, "planetIndex": 2,
"lagrangeSide": -1 "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": [ "shipFormations": [
{ {
"shipId": "constructor", "shipId": "constructor",
"count": 1, "count": 1,
"center": [ "center": [ 40, 0, 12 ],
45, "systemId": "helios",
0, "factionId": "sol-dominion"
20
],
"systemId": "helios"
}, },
{ {
"shipId": "miner", "shipId": "miner",
"count": 1, "count": 1,
"center": [ "center": [ 54, 0, 18 ],
52, "systemId": "helios",
0, "factionId": "sol-dominion"
24
],
"systemId": "helios"
}, },
{ {
"shipId": "hauler", "shipId": "hauler",
"count": 1, "count": 1,
"center": [ "center": [ 62, 0, 8 ],
60, "systemId": "helios",
0, "factionId": "sol-dominion"
28 },
], {
"systemId": "helios" "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": [], "patrolRoutes": [],

View File

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

View File

@@ -423,23 +423,44 @@
{ {
"angle": 0.45, "angle": 0.45,
"radiusOffset": 180000, "radiusOffset": 180000,
"oreAmount": 3000, "oreAmount": 12000,
"itemId": "ore", "itemId": "ore",
"shardCount": 7 "shardCount": 7
}, },
{ {
"angle": 2.544395102, "angle": 2.544395102,
"radiusOffset": 180000, "radiusOffset": 180000,
"oreAmount": 3000, "oreAmount": 12000,
"itemId": "ore", "itemId": "ore",
"shardCount": 7 "shardCount": 7
}, },
{ {
"angle": 4.638790205, "angle": 4.638790205,
"radiusOffset": 180000, "radiusOffset": 180000,
"oreAmount": 3000, "oreAmount": 12000,
"itemId": "ore", "itemId": "ore",
"shardCount": 7 "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": [ "planets": [